feat: add captcha-backed forum security hardening

This commit is contained in:
2026-03-17 16:06:28 +01:00
parent 980a15f66e
commit b3fc889452
40 changed files with 2849 additions and 108 deletions

View File

@@ -8,6 +8,8 @@ import Toggle from '../../components/ui/Toggle'
import Select from '../../components/ui/Select'
import Modal from '../../components/ui/Modal'
import { RadioGroup } from '../../components/ui/Radio'
import { buildBotFingerprint } from '../../lib/security/botFingerprint'
import TurnstileField from '../../components/security/TurnstileField'
const SETTINGS_SECTIONS = [
{ key: 'profile', label: 'Profile', icon: 'fa-solid fa-user-astronaut', description: 'Public identity and avatar.' },
@@ -57,6 +59,16 @@ function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
async function botHeaders(extra = {}, captcha = {}) {
const fingerprint = await buildBotFingerprint()
return {
...extra,
'X-Bot-Fingerprint': fingerprint,
...(captcha?.token ? { 'X-Captcha-Token': captcha.token } : {}),
}
}
function toIsoDate(day, month, year) {
if (!day || !month || !year) return ''
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
@@ -122,6 +134,8 @@ export default function ProfileEdit() {
usernameCooldownDays = 30,
usernameCooldownRemainingDays = 0,
usernameCooldownActive = false,
captcha: initialCaptcha = {},
flash = {},
} = props
const fallbackDate = toIsoDate(
@@ -194,6 +208,17 @@ export default function ProfileEdit() {
notifications: {},
security: {},
})
const [captchaState, setCaptchaState] = useState({
required: !!flash?.botCaptchaRequired,
section: '',
token: '',
message: '',
nonce: 0,
provider: initialCaptcha?.provider || '',
siteKey: initialCaptcha?.siteKey || '',
inputName: initialCaptcha?.inputName || 'cf-turnstile-response',
scriptUrl: initialCaptcha?.scriptUrl || '',
})
const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl || '')
const [avatarFile, setAvatarFile] = useState(null)
@@ -346,6 +371,92 @@ export default function ProfileEdit() {
setErrorsBySection((prev) => ({ ...prev, [section]: {} }))
}
const resetCaptchaState = () => {
setCaptchaState((prev) => ({
...prev,
required: false,
section: '',
token: '',
message: '',
nonce: prev.nonce + 1,
}))
}
const captureCaptchaRequirement = (section, payload = {}) => {
const requiresCaptcha = !!(payload?.requires_captcha || payload?.requiresCaptcha)
if (!requiresCaptcha) {
return false
}
const nextCaptcha = payload?.captcha || {}
const message = payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.'
setCaptchaState((prev) => ({
required: true,
section,
token: '',
message,
nonce: prev.nonce + 1,
provider: nextCaptcha.provider || payload?.captcha_provider || prev.provider || initialCaptcha?.provider || 'turnstile',
siteKey: nextCaptcha.siteKey || payload?.captcha_site_key || prev.siteKey || initialCaptcha?.siteKey || '',
inputName: nextCaptcha.inputName || payload?.captcha_input || prev.inputName || initialCaptcha?.inputName || 'cf-turnstile-response',
scriptUrl: nextCaptcha.scriptUrl || payload?.captcha_script_url || prev.scriptUrl || initialCaptcha?.scriptUrl || '',
}))
updateSectionErrors(section, {
_general: [message],
captcha: [message],
})
return true
}
const applyCaptchaPayload = (payload = {}) => {
if (!captchaState.required || !captchaState.inputName) {
return payload
}
return {
...payload,
[captchaState.inputName]: captchaState.token || '',
}
}
const applyCaptchaFormData = (formData) => {
if (captchaState.required && captchaState.inputName) {
formData.set(captchaState.inputName, captchaState.token || '')
}
}
const renderCaptchaChallenge = (section, placement = 'section') => {
if (!captchaState.required || !captchaState.siteKey || activeSection !== section) {
return null
}
if (section === 'account' && showEmailChangeModal && placement !== 'modal') {
return null
}
if (section === 'account' && !showEmailChangeModal && placement === 'modal') {
return null
}
return (
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 p-4">
<p className="mb-3 text-sm text-amber-100">{captchaState.message || 'Complete the captcha challenge to continue.'}</p>
<TurnstileField
key={`${section}-${placement}-${captchaState.nonce}`}
provider={captchaState.provider}
siteKey={captchaState.siteKey}
scriptUrl={captchaState.scriptUrl}
onToken={(token) => setCaptchaState((prev) => ({ ...prev, token }))}
className="rounded-lg border border-white/10 bg-black/20 p-3"
/>
</div>
)
}
const switchSection = (nextSection) => {
if (activeSection === nextSection) return
if (dirtyMap[activeSection]) {
@@ -397,19 +508,23 @@ export default function ProfileEdit() {
if (avatarFile) {
formData.append('avatar', avatarFile)
}
applyCaptchaFormData(formData)
const response = await fetch('/settings/profile/update', {
method: 'POST',
credentials: 'same-origin',
headers: {
headers: await botHeaders({
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
}, captchaState),
body: formData,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('profile', payload)) {
return
}
updateSectionErrors('profile', payload.errors || { _general: [payload.message || 'Unable to save profile section.'] })
return
}
@@ -421,6 +536,7 @@ export default function ProfileEdit() {
setAvatarFile(null)
setAvatarPosition('center')
setRemoveAvatar(false)
resetCaptchaState()
setSavedMessage({ section: 'profile', text: payload.message || 'Profile updated successfully.' })
} catch (error) {
updateSectionErrors('profile', { _general: ['Request failed. Please try again.'] })
@@ -446,21 +562,25 @@ export default function ProfileEdit() {
const response = await fetch('/settings/account/username', {
method: 'POST',
credentials: 'same-origin',
headers: {
headers: await botHeaders({
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
body: JSON.stringify({ username: accountForm.username }),
}, captchaState),
body: JSON.stringify(applyCaptchaPayload({ username: accountForm.username, homepage_url: '' })),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('account', payload)) {
return
}
updateSectionErrors('account', payload.errors || { _general: [payload.message || 'Unable to save account section.'] })
return
}
initialRef.current.accountForm = { ...accountForm }
resetCaptchaState()
setSavedMessage({ section: 'account', text: payload.message || 'Account updated successfully.' })
} catch (error) {
updateSectionErrors('account', { _general: ['Request failed. Please try again.'] })
@@ -478,21 +598,26 @@ export default function ProfileEdit() {
const response = await fetch('/settings/email/request', {
method: 'POST',
credentials: 'same-origin',
headers: {
headers: await botHeaders({
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
body: JSON.stringify({ new_email: emailChangeForm.new_email }),
}, captchaState),
body: JSON.stringify(applyCaptchaPayload({ new_email: emailChangeForm.new_email, homepage_url: '' })),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('account', payload)) {
setEmailChangeError(payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.')
return
}
setEmailChangeError(payload?.errors?.new_email?.[0] || payload?.message || 'Unable to request email change.')
return
}
setEmailChangeStep('verify')
resetCaptchaState()
setEmailChangeInfo(payload.message || 'Verification code sent to your new email address.')
} catch (error) {
setEmailChangeError('Request failed. Please try again.')
@@ -510,16 +635,20 @@ export default function ProfileEdit() {
const response = await fetch('/settings/email/verify', {
method: 'POST',
credentials: 'same-origin',
headers: {
headers: await botHeaders({
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
body: JSON.stringify({ code: emailChangeForm.code }),
}, captchaState),
body: JSON.stringify(applyCaptchaPayload({ code: emailChangeForm.code, homepage_url: '' })),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('account', payload)) {
setEmailChangeError(payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.')
return
}
setEmailChangeError(payload?.errors?.code?.[0] || payload?.message || 'Verification failed.')
return
}
@@ -530,6 +659,7 @@ export default function ProfileEdit() {
setShowEmailChangeModal(false)
setEmailChangeStep('request')
setEmailChangeForm({ new_email: '', code: '' })
resetCaptchaState()
setSavedMessage({ section: 'account', text: payload.message || 'Email updated successfully.' })
} catch (error) {
setEmailChangeError('Request failed. Please try again.')
@@ -547,25 +677,30 @@ export default function ProfileEdit() {
const response = await fetch('/settings/personal/update', {
method: 'POST',
credentials: 'same-origin',
headers: {
headers: await botHeaders({
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
body: JSON.stringify({
}, captchaState),
body: JSON.stringify(applyCaptchaPayload({
birthday: toIsoDate(personalForm.day, personalForm.month, personalForm.year) || null,
gender: personalForm.gender || null,
country: personalForm.country || null,
}),
homepage_url: '',
})),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('personal', payload)) {
return
}
updateSectionErrors('personal', payload.errors || { _general: [payload.message || 'Unable to save personal details.'] })
return
}
initialRef.current.personalForm = { ...personalForm }
resetCaptchaState()
setSavedMessage({ section: 'personal', text: payload.message || 'Personal details saved successfully.' })
} catch (error) {
updateSectionErrors('personal', { _general: ['Request failed. Please try again.'] })
@@ -583,21 +718,25 @@ export default function ProfileEdit() {
const response = await fetch('/settings/notifications/update', {
method: 'POST',
credentials: 'same-origin',
headers: {
headers: await botHeaders({
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
body: JSON.stringify(notificationForm),
}, captchaState),
body: JSON.stringify(applyCaptchaPayload({ ...notificationForm, homepage_url: '' })),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('notifications', payload)) {
return
}
updateSectionErrors('notifications', payload.errors || { _general: [payload.message || 'Unable to save notifications.'] })
return
}
initialRef.current.notificationForm = { ...notificationForm }
resetCaptchaState()
setSavedMessage({ section: 'notifications', text: payload.message || 'Notification settings saved successfully.' })
} catch (error) {
updateSectionErrors('notifications', { _general: ['Request failed. Please try again.'] })
@@ -615,16 +754,19 @@ export default function ProfileEdit() {
const response = await fetch('/settings/security/password', {
method: 'POST',
credentials: 'same-origin',
headers: {
headers: await botHeaders({
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
body: JSON.stringify(securityForm),
}, captchaState),
body: JSON.stringify(applyCaptchaPayload({ ...securityForm, homepage_url: '' })),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('security', payload)) {
return
}
updateSectionErrors('security', payload.errors || { _general: [payload.message || 'Unable to update password.'] })
return
}
@@ -634,6 +776,7 @@ export default function ProfileEdit() {
new_password: '',
new_password_confirmation: '',
})
resetCaptchaState()
setSavedMessage({ section: 'security', text: payload.message || 'Password updated successfully.' })
} catch (error) {
updateSectionErrors('security', { _general: ['Request failed. Please try again.'] })
@@ -857,6 +1000,8 @@ export default function ProfileEdit() {
rows={3}
error={errorsBySection.profile.description?.[0]}
/>
{renderCaptchaChallenge('profile')}
</div>
</div>
</SectionCard>
@@ -933,6 +1078,8 @@ export default function ProfileEdit() {
<p className="mt-4 rounded-lg border border-white/10 bg-white/[0.02] px-3 py-2 text-xs text-slate-300">
You can change your username once every {usernameCooldownDays} days.
</p>
{renderCaptchaChallenge('account')}
</SectionCard>
</form>
) : null}
@@ -1034,6 +1181,8 @@ export default function ProfileEdit() {
error={errorsBySection.personal.country?.[0]}
/>
)}
{renderCaptchaChallenge('personal')}
</div>
</SectionCard>
</form>
@@ -1085,6 +1234,8 @@ export default function ProfileEdit() {
/>
</div>
))}
{renderCaptchaChallenge('notifications')}
</div>
</SectionCard>
</form>
@@ -1152,6 +1303,8 @@ export default function ProfileEdit() {
<div className="rounded-lg border border-white/5 bg-white/[0.02] p-3 text-xs text-slate-400">
Future security controls: Two-factor authentication, active sessions, and login history.
</div>
{renderCaptchaChallenge('security')}
</div>
</SectionCard>
</form>
@@ -1221,6 +1374,8 @@ export default function ProfileEdit() {
</div>
) : null}
{renderCaptchaChallenge('account', 'modal')}
{emailChangeStep === 'request' ? (
<TextInput
label="Enter new email address"