-
Notifications
You must be signed in to change notification settings - Fork 64
feat: add honeypots and (mostly) invisible PoW captchas to highly spam prone forms #3488
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
da876d7
174fffa
5c9d2bd
26dff7a
aec4f86
1abb43d
396f6bd
ab023fc
7843395
d75a949
36f22c8
3715229
31b9f95
f7eab42
e7f7c42
0428725
5cf9d94
09ed4bf
d23b55d
6127ddb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,199 @@ | ||
| import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; | ||
|
|
||
| import { usePageContext } from 'utils/hooks'; | ||
|
|
||
| export type AltchaRef = { | ||
| value: string | null; | ||
| verify: () => Promise<string>; | ||
| }; | ||
|
|
||
| type AltchaProps = { | ||
| challengeurl?: string; | ||
| auto?: 'off' | 'onfocus' | 'onload' | 'onsubmit'; | ||
| onStateChange?: (ev: Event | CustomEvent<{ payload?: string; state: string }>) => void; | ||
| style?: React.CSSProperties & Record<string, string>; | ||
| }; | ||
|
|
||
| const DEFAULT_CHALLENGE_URL = '/api/captcha/challenge'; | ||
|
|
||
| type WidgetElement = HTMLElement & AltchaWidgetMethods; | ||
|
|
||
| const Altcha = forwardRef<AltchaRef, AltchaProps>((props, ref) => { | ||
| const { challengeurl = DEFAULT_CHALLENGE_URL, auto, onStateChange, style } = props; | ||
| const { locationData } = usePageContext(); | ||
| const devMode = !locationData.isProd; | ||
| const widgetRef = useRef<WidgetElement | null>(null); | ||
| const [value, setValue] = useState<string | null>(null); | ||
| const [loaded, setLoaded] = useState(false); | ||
| const [simulateFailure, setSimulateFailure] = useState(false); | ||
| const [widgetKey, setWidgetKey] = useState(0); | ||
| const valueRef = useRef<string | null>(null); | ||
| valueRef.current = value; | ||
|
|
||
| useEffect(() => { | ||
| import('altcha').then(() => setLoaded(true)); | ||
| }, []); | ||
|
|
||
| const [altchaVisible, setAltchaVisible] = useState<boolean>(false); | ||
| // biome-ignore lint/correctness/useExhaustiveDependencies: widgetKey triggers re-attach after remount | ||
| useEffect(() => { | ||
| if (!loaded) return; | ||
| const w = widgetRef.current; | ||
| if (!w) return; | ||
| const handleStateChange = (ev: Event) => { | ||
| const e = ev as CustomEvent<{ payload?: string; state: string }>; | ||
| console.log('state changed', e.detail); | ||
|
|
||
| switch (e.detail.state) { | ||
| case 'error': | ||
| case 'code': | ||
| case 'unverified': | ||
| setAltchaVisible(true); | ||
| break; | ||
| case 'verifying': | ||
| if (devMode) { | ||
| setAltchaVisible(true); | ||
| } | ||
| break; | ||
| case 'verified': | ||
| if (e.detail.payload) { | ||
| setValue(e.detail.payload); | ||
| setAltchaVisible(false); | ||
| } | ||
| break; | ||
| default: | ||
| break; | ||
| } | ||
|
|
||
| onStateChange?.(e); | ||
| }; | ||
| w.addEventListener('statechange', handleStateChange); | ||
| return () => w.removeEventListener('statechange', handleStateChange); | ||
| }, [loaded, onStateChange, widgetKey]); | ||
|
|
||
| // biome-ignore lint/correctness/useExhaustiveDependencies: widgetKey triggers re-bind after remount | ||
| useImperativeHandle( | ||
| ref, | ||
| () => ({ | ||
| get value() { | ||
| return valueRef.current; | ||
| }, | ||
| verify(): Promise<string> { | ||
| const w = widgetRef.current; | ||
| if (!w) return Promise.reject(new Error('Altcha widget not mounted')); | ||
| const current = valueRef.current; | ||
| if (current) return Promise.resolve(current); | ||
| return new Promise((resolve, reject) => { | ||
| const handler = (ev: Event) => { | ||
| const e = ev as CustomEvent<{ payload?: string; state: string }>; | ||
| const state = e.detail?.state; | ||
| if (state === 'verified' && e.detail?.payload) { | ||
| w.removeEventListener('statechange', handler); | ||
| resolve(e.detail.payload); | ||
| return; | ||
| } | ||
| if (state === 'error' || state === 'expired') { | ||
| w.removeEventListener('statechange', handler); | ||
| reject(new Error('Captcha verification failed')); | ||
| } | ||
| }; | ||
| w.addEventListener('statechange', handler); | ||
| w.verify(); | ||
| }); | ||
| }, | ||
| }), | ||
| [widgetKey], | ||
| ); | ||
|
|
||
| const handleToggleFailure = () => { | ||
| setSimulateFailure((prev) => !prev); | ||
| setValue(null); | ||
| setWidgetKey((k) => k + 1); | ||
| }; | ||
|
|
||
| const handleReset = () => { | ||
| setValue(null); | ||
| widgetRef.current?.reset(); | ||
| }; | ||
|
|
||
| if (!loaded) return null; | ||
|
|
||
| const devAttrs = devMode ? { debug: true, floatingpersist: 'focus' as const } : {}; | ||
|
|
||
| const widget = ( | ||
| <React.Fragment key={widgetKey}> | ||
| <altcha-widget | ||
| delay={500} | ||
| ref={widgetRef as any} | ||
| challengeurl={challengeurl} | ||
| {...(auto ? { auto } : {})} | ||
| floating="auto" | ||
| {...devAttrs} | ||
| {...(simulateFailure ? { mockerror: true } : {})} | ||
| style={{ | ||
| display: altchaVisible ? 'block' : 'none', | ||
| zIndex: 1000, | ||
| ...(style ? ({ style } as any) : {}), | ||
| }} | ||
| // disable very annoying wait alert | ||
| strings="{"waitAlert":""}" | ||
| /> | ||
| </React.Fragment> | ||
| ); | ||
|
|
||
| if (!devMode) return widget; | ||
|
|
||
| return ( | ||
| <div | ||
| style={{ | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| gap: 6, | ||
| fontSize: 11, | ||
| color: '#5c7080', | ||
| padding: '2px 6px', | ||
| border: '1px dashed #5c7080', | ||
| borderRadius: 3, | ||
| }} | ||
| > | ||
| {widget} | ||
| <span style={{ fontWeight: 600 }}>Captcha</span> | ||
| <label | ||
| style={{ | ||
| cursor: 'pointer', | ||
| display: 'inline-flex', | ||
| alignItems: 'center', | ||
| gap: 3, | ||
| color: simulateFailure ? '#db3737' : undefined, | ||
| }} | ||
| > | ||
| <input | ||
| type="checkbox" | ||
| checked={simulateFailure} | ||
| onChange={handleToggleFailure} | ||
| style={{ margin: 0 }} | ||
| /> | ||
| fail | ||
| </label> | ||
| <button | ||
| type="button" | ||
| onClick={handleReset} | ||
| style={{ | ||
| fontSize: 11, | ||
| padding: '1px 6px', | ||
| cursor: 'pointer', | ||
| border: '1px solid #ced9e0', | ||
| borderRadius: 3, | ||
| background: 'white', | ||
| lineHeight: '16px', | ||
| }} | ||
| > | ||
| reset | ||
| </button> | ||
| </div> | ||
| ); | ||
| }); | ||
|
|
||
| Altcha.displayName = 'Altcha'; | ||
|
|
||
| export default Altcha; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { type AltchaRef, default } from './Altcha'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,35 +1,72 @@ | ||
| import React, { useState } from 'react'; | ||
| import type * as types from 'types'; | ||
|
|
||
| import React, { useCallback, useRef, useState } from 'react'; | ||
|
|
||
| import { apiFetch } from 'client/utils/apiFetch'; | ||
| import { Altcha, Honeypot } from 'components'; | ||
| import { usePageContext } from 'utils/hooks'; | ||
|
|
||
| import GlobalControlsButton from './GlobalControlsButton'; | ||
|
|
||
| const CreatePubButton = () => { | ||
| const [isLoading, setIsLoading] = useState(false); | ||
| const altchaRef = useRef<import('components').AltchaRef>(null); | ||
| const { communityData } = usePageContext(); | ||
|
|
||
| const handleCreatePub = () => { | ||
| setIsLoading(true); | ||
| return apiFetch | ||
| .post('/api/pubs', { communityId: communityData.id }) | ||
| .then((newPub) => { | ||
| const handleCreatePub = useCallback( | ||
| async (evt: React.FormEvent<HTMLFormElement>) => { | ||
| evt.preventDefault(); | ||
| const formData = new FormData(evt.currentTarget); | ||
| const honeypot = (formData.get('description') as string) ?? ''; | ||
| setIsLoading(true); | ||
| try { | ||
| const altchaPayload = await altchaRef.current?.verify(); | ||
| if (!altchaPayload) return; | ||
| const newPub = await apiFetch.post<types.Pub>('/api/pubs/fromForm', { | ||
| communityId: communityData.id, | ||
| altcha: altchaPayload, | ||
| _honeypot: honeypot, | ||
| }); | ||
| window.location.href = `/pub/${newPub.slug}`; | ||
| }) | ||
| .catch((err) => { | ||
| console.error(err); | ||
| } catch (error) { | ||
| console.error('Error in handleCreatePub', error); | ||
| } finally { | ||
| setIsLoading(false); | ||
| }); | ||
| }; | ||
| } | ||
| }, | ||
| [communityData.id], | ||
| ); | ||
|
|
||
| const formRef = useRef<HTMLFormElement>(null); | ||
|
|
||
| return ( | ||
| <GlobalControlsButton | ||
| loading={isLoading} | ||
| aria-label="Create Pub" | ||
| onClick={handleCreatePub} | ||
| desktop={{ text: 'Create Pub' }} | ||
| mobile={{ icon: 'pubDocNew' }} | ||
| /> | ||
| <form | ||
| onSubmit={handleCreatePub} | ||
| ref={formRef} | ||
| // only relevant in dev | ||
| style={{ display: 'flex', alignItems: 'center', gap: '10px' }} | ||
| > | ||
| <Altcha | ||
| ref={altchaRef} | ||
| onStateChange={(state) => { | ||
| if (!isLoading) { | ||
| setIsLoading(true); | ||
| } | ||
| // @ts-ignore | ||
| if (state.detail.state === 'verified') { | ||
| setIsLoading(false); | ||
| } | ||
| }} | ||
|
Comment on lines
+51
to
+59
|
||
| /> | ||
| <Honeypot name="description" /> | ||
| <GlobalControlsButton | ||
| loading={isLoading} | ||
| aria-label="Create Pub" | ||
| type="submit" | ||
| desktop={{ text: 'Create Pub' }} | ||
| mobile={{ icon: 'pubDocNew' }} | ||
| /> | ||
| </form> | ||
| ); | ||
| }; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import React from 'react'; | ||
|
|
||
| import { usePageContext } from 'utils/hooks'; | ||
|
|
||
| import './honeypot.scss'; | ||
|
|
||
| type HoneypotProps = { | ||
| name: string; | ||
| }; | ||
|
|
||
| const Honeypot = (props: HoneypotProps) => { | ||
| const { name } = props; | ||
| const { locationData } = usePageContext(); | ||
| const devMode = !locationData.isProd; | ||
|
|
||
| if (devMode) { | ||
| return ( | ||
| <label | ||
| style={{ | ||
| display: 'inline-flex', | ||
| alignItems: 'center', | ||
| gap: 4, | ||
| fontSize: 11, | ||
| color: 'orange', | ||
| fontWeight: 600, | ||
| padding: '2px 6px', | ||
| border: '1px dashed orange', | ||
| borderRadius: 3, | ||
| }} | ||
| > | ||
| Honeypot | ||
| <input | ||
| type="text" | ||
| name={name} | ||
| autoComplete="off" | ||
| style={{ width: 80, fontSize: 11, padding: '1px 4px' }} | ||
| /> | ||
| </label> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <input | ||
| type="text" | ||
| className="honeypot-input" | ||
| name={name} | ||
| tabIndex={-1} | ||
| autoComplete="off" | ||
| aria-hidden="true" | ||
| /> | ||
| ); | ||
| }; | ||
|
|
||
| export default Honeypot; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| .honeypot-input { | ||
| position: absolute; | ||
| left: -9999px; | ||
| width: 1px; | ||
| height: 1px; | ||
| opacity: 0; | ||
| pointer-events: none; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default } from './Honeypot'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The console.log statement should be removed before merging to production. Debug logging statements can add unnecessary noise to production logs and may expose implementation details about the captcha state machine.