From 7518e164a531210893db423ff1064871e3e18f5a Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Sun, 31 Mar 2024 23:57:34 +0800 Subject: [PATCH 1/9] Utils for recaptcha --- packages/api/src/routers/auth/auth_router.ts | 21 ++++++++- packages/api/src/utils/recaptcha.ts | 49 ++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 packages/api/src/utils/recaptcha.ts diff --git a/packages/api/src/routers/auth/auth_router.ts b/packages/api/src/routers/auth/auth_router.ts index 01be9c019..da7443587 100644 --- a/packages/api/src/routers/auth/auth_router.ts +++ b/packages/api/src/routers/auth/auth_router.ts @@ -47,6 +47,7 @@ import { } from './google_auth' import { createWebAuthToken } from './jwt_helpers' import { createMobileAccountCreationResponse } from './mobile/account_creation' +import { verifyChallengeRecaptcha } from '../../utils/recaptcha' export interface SignupRequest { email: string @@ -55,6 +56,7 @@ export interface SignupRequest { username: string bio?: string pictureUrl?: string + recaptchaToken?: string } const signToken = promisify(jwt.sign) @@ -499,7 +501,24 @@ export function authRouter() { `${env.client.url}/auth/email-signup?errorCodes=INVALID_CREDENTIALS` ) } - const { email, password, name, username, bio, pictureUrl } = req.body + const { + email, + password, + name, + username, + bio, + pictureUrl, + recaptchaToken, + } = req.body + + if (recaptchaToken) { + const verified = await verifyChallengeRecaptcha(recaptchaToken) + if (!verified) { + return res.redirect( + `${env.client.url}/auth/email-signup?errorCodes=UNKNOWN` + ) + } + } function isURLPresent(input: string): boolean { const urlRegex = /(https?:\/\/[^\s]+)/g diff --git a/packages/api/src/utils/recaptcha.ts b/packages/api/src/utils/recaptcha.ts new file mode 100644 index 000000000..2fa69a626 --- /dev/null +++ b/packages/api/src/utils/recaptcha.ts @@ -0,0 +1,49 @@ +import axios from 'axios' + +type RecaptchaResponse = { + success: Boolean + hostname: string + score?: number + action?: string +} + +const isRecaptchaResponse = (data: any): data is RecaptchaResponse => { + return ( + 'success' in data && + 'hostname' in data && + 'score' in data && + 'action' in data + ) +} + +export const verifyChallengeRecaptcha = async ( + token: string +): Promise => { + if (!process.env.RECAPTCHA_CHALLENGE_SECRET_KEY) { + return false + } + + const url = `https://www.google.com/recaptcha/api/siteverify` + const params = new URLSearchParams({ + secret: process.env.RECAPTCHA_CHALLENGE_SECRET_KEY, + response: token, + }) + + try { + const response = await axios.post(url, params) + console.log('recaptcha response: ', response) + + if (!response.data || !response.data.success) { + throw new Error('Failed to verify reCAPTCHA') + } + + const json = response.data + if (!isRecaptchaResponse(json)) { + return false + } + return json.success + } catch (error) { + console.error('Error verifying reCAPTCHA:', error) + return false + } +} From 2257a5a42e26836c9db1a979c0afc5d4273efe52 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Sun, 31 Mar 2024 23:58:21 +0800 Subject: [PATCH 2/9] Add web recaptcha on signup --- .../components/templates/auth/EmailSignup.tsx | 389 ++++++++++-------- packages/web/next.config.js | 6 +- packages/web/package.json | 3 +- packages/web/pages/auth/email-signup.tsx | 19 +- 4 files changed, 237 insertions(+), 180 deletions(-) diff --git a/packages/web/components/templates/auth/EmailSignup.tsx b/packages/web/components/templates/auth/EmailSignup.tsx index 1f920f7ba..9cd21d206 100644 --- a/packages/web/components/templates/auth/EmailSignup.tsx +++ b/packages/web/components/templates/auth/EmailSignup.tsx @@ -1,7 +1,7 @@ import { HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives' import { Button } from '../../elements/Button' import { StyledText, StyledTextSpan } from '../../elements/StyledText' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { BorderedFormInput, FormLabel } from '../../elements/FormElements' import { TermAndConditionsFooter } from '../LoginForm' import { fetchEndpoint } from '../../../lib/appConfig' @@ -10,26 +10,19 @@ import { logoutMutation } from '../../../lib/networking/mutations/logoutMutation import { useRouter } from 'next/router' import { formatMessage } from '../../../locales/en/messages' import { parseErrorCodes } from '../../../lib/queryParamParser' +import { + GoogleReCaptchaProvider, + GoogleReCaptchaCheckbox, +} from '@google-recaptcha/react' import Link from 'next/link' -export function EmailSignup(): JSX.Element { - const router = useRouter() +const SignUpForm = (): JSX.Element => { const [email, setEmail] = useState() const [password, setPassword] = useState() const [fullname, setFullname] = useState() const [username, setUsername] = useState() const [debouncedUsername, setDebouncedUsername] = useState() - const [errorMessage, setErrorMessage] = useState() - - useEffect(() => { - if (!router.isReady) return - const errorCode = parseErrorCodes(router.query) - const errorMsg = errorCode - ? formatMessage({ id: `error.${errorCode}` }) - : undefined - setErrorMessage(errorMsg) - }, [router.isReady, router.query]) const { isUsernameValid, usernameErrorMessage } = useValidateUsernameQuery({ username: debouncedUsername ?? '', @@ -46,174 +39,228 @@ export function EmailSignup(): JSX.Element { ) return ( -
- - - Sign Up - - - - Email - { - e.preventDefault() - setEmail(e.target.value) - }} - required - /> - - - - Password - setPassword(e.target.value)} - required - /> - - - - Full Name - setFullname(e.target.value)} - required - /> - - - - Username - - - {username && username.length > 0 && usernameErrorMessage && ( - - {usernameErrorMessage} - - )} - {isUsernameValid && ( - - Username is available. - - )} - - - {errorMessage && {errorMessage}} - + + + Email + { + e.preventDefault() + setEmail(e.target.value) + }} + required + /> + + + Password + setPassword(e.target.value)} + required + /> + + + Full Name + setFullname(e.target.value)} + required + /> + + + Username + + + {username && username.length > 0 && usernameErrorMessage && ( - Omnivore will send you daily tips for your first week as a new user. - If you don't like them you can unsubscribe. + {usernameErrorMessage} - - - - - - + )} + {isUsernameValid && ( - Already have an account?{' '} - - - Login instead - - + Username is available. - - - + )} +
+ ) +} + +type RecaptchaProps = { + setRecaptchaToken: (string) => void +} + +const Recaptcha = (props: RecaptchaProps): JSX.Element => { + return ( + <> + { + console.log('recaptcha: ', token) + props.setRecaptchaToken(token) + }} + /> + + ) +} + +export function EmailSignup(): JSX.Element { + const router = useRouter() + const recaptchaTokenRef = useRef(null) + const [errorMessage, setErrorMessage] = useState() + + useEffect(() => { + if (!router.isReady) return + const errorCode = parseErrorCodes(router.query) + const errorMsg = errorCode + ? formatMessage({ id: `error.${errorCode}` }) + : undefined + setErrorMessage(errorMsg) + }, [router.isReady, router.query]) + + return ( + <> +
+ + + Sign Up + + + + + { + if (recaptchaTokenRef.current) { + recaptchaTokenRef.current.value = token + } else { + console.log('error updating recaptcha token') + } + }} + /> + + + {errorMessage && ( + {errorMessage} + )} + + + Omnivore will send you daily tips for your first week as a new user. + If you don't like them you can unsubscribe. + + + + + + + + + Already have an account?{' '} + + + Login instead + + + + + +
+ ) } diff --git a/packages/web/next.config.js b/packages/web/next.config.js index 07228c7f9..6cf984120 100644 --- a/packages/web/next.config.js +++ b/packages/web/next.config.js @@ -1,13 +1,13 @@ const ContentSecurityPolicy = ` default-src 'self'; base-uri 'self'; - connect-src 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://proxy-prod.omnivore-image-cache.app https://accounts.google.com https://proxy-demo.omnivore-image-cache.app https://storage.googleapis.com https://api.segment.io https://cdn.segment.com https://widget.intercom.io https://api-iam.intercom.io https://static.intercomassets.com https://downloads.intercomcdn.com https://platform.twitter.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io wss://nexus-europe-websocket.intercom.io wss://nexus-australia-websocket.intercom.io https://uploads.intercomcdn.com https://tools.applemediaservices.com; + connect-src 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://proxy-prod.omnivore-image-cache.app https://accounts.google.com https://proxy-demo.omnivore-image-cache.app https://storage.googleapis.com https://widget.intercom.io https://api-iam.intercom.io https://static.intercomassets.com https://downloads.intercomcdn.com https://platform.twitter.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io wss://nexus-europe-websocket.intercom.io wss://nexus-australia-websocket.intercom.io https://uploads.intercomcdn.com https://tools.applemediaservices.com; font-src 'self' data: https://cdn.jsdelivr.net https://js.intercomcdn.com https://fonts.intercomcdn.com; form-action 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://getpocket.com/auth/authorize https://intercom.help https://api-iam.intercom.io https://api-iam.eu.intercom.io https://api-iam.au.intercom.io https://www.notion.so https://api.notion.com; frame-ancestors 'none'; - frame-src 'self' https://accounts.google.com https://platform.twitter.com https://www.youtube.com https://www.youtube-nocookie.com; + frame-src 'self' https://accounts.google.com https://platform.twitter.com https://www.youtube.com https://www.youtube-nocookie.com https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/; manifest-src 'self'; - script-src 'self' 'unsafe-inline' 'unsafe-eval' accounts.google.com https://widget.intercom.io https://js.intercomcdn.com https://platform.twitter.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://cdn.segment.com; + script-src 'self' 'unsafe-inline' 'unsafe-eval' accounts.google.com https://widget.intercom.io https://js.intercomcdn.com https://platform.twitter.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; style-src 'self' 'unsafe-inline' https://accounts.google.com https://cdnjs.cloudflare.com; img-src 'self' blob: data: https:; worker-src 'self' blob:; diff --git a/packages/web/package.json b/packages/web/package.json index 467f44d12..c10a0573c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@floating-ui/react": "^0.26.9", + "@google-recaptcha/react": "^1.0.3", "@radix-ui/react-avatar": "^0.1.1", "@radix-ui/react-checkbox": "^0.1.5", "@radix-ui/react-dialog": "1.0.5", @@ -106,4 +107,4 @@ "volta": { "extends": "../../package.json" } -} \ No newline at end of file +} diff --git a/packages/web/pages/auth/email-signup.tsx b/packages/web/pages/auth/email-signup.tsx index 7c2d24d8d..3487ecc8c 100644 --- a/packages/web/pages/auth/email-signup.tsx +++ b/packages/web/pages/auth/email-signup.tsx @@ -1,15 +1,24 @@ import { PageMetaData } from '../../components/patterns/PageMetaData' import { ProfileLayout } from '../../components/templates/ProfileLayout' import { EmailSignup } from '../../components/templates/auth/EmailSignup' +import { GoogleReCaptchaProvider } from '@google-recaptcha/react' export default function EmailRegistrationPage(): JSX.Element { return ( <> - - - - -
+ + + + + + ) } From 442475d684ebdec3a1522bf2f4e8903dc1d024d8 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 1 Apr 2024 00:05:24 +0800 Subject: [PATCH 3/9] Make this optional for self hosters --- packages/api/src/routers/auth/auth_router.ts | 2 +- .../components/templates/auth/EmailSignup.tsx | 28 ++++++++++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/api/src/routers/auth/auth_router.ts b/packages/api/src/routers/auth/auth_router.ts index da7443587..e9e288e1a 100644 --- a/packages/api/src/routers/auth/auth_router.ts +++ b/packages/api/src/routers/auth/auth_router.ts @@ -511,7 +511,7 @@ export function authRouter() { recaptchaToken, } = req.body - if (recaptchaToken) { + if (recaptchaToken && process.env.RECAPTCHA_CHALLENGE_SECRET_KEY) { const verified = await verifyChallengeRecaptcha(recaptchaToken) if (!verified) { return res.redirect( diff --git a/packages/web/components/templates/auth/EmailSignup.tsx b/packages/web/components/templates/auth/EmailSignup.tsx index 9cd21d206..f5b71916d 100644 --- a/packages/web/components/templates/auth/EmailSignup.tsx +++ b/packages/web/components/templates/auth/EmailSignup.tsx @@ -180,16 +180,24 @@ export function EmailSignup(): JSX.Element { - { - if (recaptchaTokenRef.current) { - recaptchaTokenRef.current.value = token - } else { - console.log('error updating recaptcha token') - } - }} - /> - + {process.env.NEXT_PUBLIC_RECAPTCHA_CHALLENGE_SECRET_KEY && ( + <> + { + if (recaptchaTokenRef.current) { + recaptchaTokenRef.current.value = token + } else { + console.log('error updating recaptcha token') + } + }} + /> + + + )} {errorMessage && ( {errorMessage} From 9a159084c89621fde59541d810035362c3f2b260 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 1 Apr 2024 10:54:49 +0800 Subject: [PATCH 4/9] Update the isValidSignUp to check for URLs --- packages/api/src/routers/auth/auth_router.ts | 21 ++++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/api/src/routers/auth/auth_router.ts b/packages/api/src/routers/auth/auth_router.ts index e9e288e1a..062211539 100644 --- a/packages/api/src/routers/auth/auth_router.ts +++ b/packages/api/src/routers/auth/auth_router.ts @@ -66,20 +66,28 @@ const cookieParams = { maxAge: 365 * 24 * 60 * 60 * 1000, } +const isURLPresent = (input: string): boolean => { + const urlRegex = /(https?:\/\/[^\s]+)/g + return urlRegex.test(input) +} + export const isValidSignupRequest = (obj: any): obj is SignupRequest => { return ( 'email' in obj && obj.email.trim().length > 0 && obj.email.trim().length < 512 && // email must not be empty + !isURLPresent(obj.email) && 'password' in obj && obj.password.length >= 8 && obj.password.trim().length < 512 && // password must be at least 8 characters 'name' in obj && obj.name.trim().length > 0 && obj.name.trim().length < 512 && // name must not be empty + !isURLPresent(obj.name) && 'username' in obj && obj.username.trim().length > 0 && - obj.username.trim().length < 512 // username must not be empty + obj.username.trim().length < 512 && // username must not be empty + !isURLPresent(obj.username) ) } @@ -513,6 +521,7 @@ export function authRouter() { if (recaptchaToken && process.env.RECAPTCHA_CHALLENGE_SECRET_KEY) { const verified = await verifyChallengeRecaptcha(recaptchaToken) + console.log('recaptcha result: ', verified) if (!verified) { return res.redirect( `${env.client.url}/auth/email-signup?errorCodes=UNKNOWN` @@ -520,16 +529,6 @@ export function authRouter() { } } - function isURLPresent(input: string): boolean { - const urlRegex = /(https?:\/\/[^\s]+)/g - return urlRegex.test(input) - } - - if (isURLPresent(email) || isURLPresent(name) || isURLPresent(username)) { - res.redirect(`${env.client.url}/auth/email-signup?errorCodes=UNKNOWN`) - return - } - // trim whitespace in email address const trimmedEmail = email.trim() try { From 57b0f8c5a53768d50b77eacb8d0d9d0920b38570 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 1 Apr 2024 11:05:50 +0800 Subject: [PATCH 5/9] Add tests for isValidSignupRequest --- packages/api/test/routers/auth.test.ts | 56 ++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/api/test/routers/auth.test.ts b/packages/api/test/routers/auth.test.ts index f62900e18..58bb48ab6 100644 --- a/packages/api/test/routers/auth.test.ts +++ b/packages/api/test/routers/auth.test.ts @@ -18,6 +18,7 @@ import { import * as util from '../../src/utils/sendEmail' import { createTestUser } from '../db' import { generateFakeUuid, request } from '../util' +import { isValidSignupRequest } from '../../src/routers/auth/auth_router' chai.use(sinonChai) @@ -631,13 +632,60 @@ describe('auth router', () => { 'ios' ).expect(200) const user = await userRepository.findOneByOrFail({ name }) - const { count } = await searchLibraryItems( - { query: 'in:all' }, - user.id - ) + const { count } = await searchLibraryItems({ query: 'in:all' }, user.id) expect(count).to.eql(4) }) }) }) }) + +describe('isValidSignupRequest', () => { + it('returns true for normal looking requests', async () => { + const result = isValidSignupRequest({ + email: 'email@omnivore.app', + password: 'superDuperPassword', + name: "The User's Name", + username: 'foouser', + }) + expect(result).to.be.true + }) + it('returns false for requests w/missing info', async () => { + let result = isValidSignupRequest({ + password: 'superDuperPassword', + name: "The User's Name", + username: 'foouser', + }) + expect(result).to.be.false + + result = isValidSignupRequest({ + email: 'email@omnivore.app', + name: "The User's Name", + username: 'foouser', + }) + expect(result).to.be.false + + result = isValidSignupRequest({ + email: 'email@omnivore.app', + password: 'superDuperPassword', + username: 'foouser', + }) + expect(result).to.be.false + + result = isValidSignupRequest({ + email: 'email@omnivore.app', + password: 'superDuperPassword', + name: "The User's Name", + }) + expect(result).to.be.false + }) + + it('returns false for requests w/malicious info', async () => { + let result = isValidSignupRequest({ + password: 'superDuperPassword', + name: "You've won a cake sign up here: https://foo.bar", + username: 'foouser', + }) + expect(result).to.be.false + }) +}) From 822fcb1e740d942a5849e4d8e496512986b480d5 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 1 Apr 2024 11:07:04 +0800 Subject: [PATCH 6/9] Add some logging --- packages/api/src/routers/auth/auth_router.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/api/src/routers/auth/auth_router.ts b/packages/api/src/routers/auth/auth_router.ts index 062211539..3af801ae4 100644 --- a/packages/api/src/routers/auth/auth_router.ts +++ b/packages/api/src/routers/auth/auth_router.ts @@ -521,12 +521,15 @@ export function authRouter() { if (recaptchaToken && process.env.RECAPTCHA_CHALLENGE_SECRET_KEY) { const verified = await verifyChallengeRecaptcha(recaptchaToken) - console.log('recaptcha result: ', verified) - if (!verified) { - return res.redirect( - `${env.client.url}/auth/email-signup?errorCodes=UNKNOWN` - ) - } + console.log('recaptcha result: ', recaptchaToken, verified) + // temporarily do not fail here so we can deploy this in stages + // just log the verification + + // if (!verified) { + // return res.redirect( + // `${env.client.url}/auth/email-signup?errorCodes=UNKNOWN` + // ) + // } } // trim whitespace in email address From 47d6f16961224484e9cc660544e0922804b279ff Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 1 Apr 2024 11:13:57 +0800 Subject: [PATCH 7/9] Fix signature on props --- packages/web/components/templates/auth/EmailSignup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/components/templates/auth/EmailSignup.tsx b/packages/web/components/templates/auth/EmailSignup.tsx index f5b71916d..3776e1000 100644 --- a/packages/web/components/templates/auth/EmailSignup.tsx +++ b/packages/web/components/templates/auth/EmailSignup.tsx @@ -127,7 +127,7 @@ const SignUpForm = (): JSX.Element => { } type RecaptchaProps = { - setRecaptchaToken: (string) => void + setRecaptchaToken: (token: string) => void } const Recaptcha = (props: RecaptchaProps): JSX.Element => { From 0fe471da826826d1fe4204b970d237f720b4f4e8 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 1 Apr 2024 11:32:10 +0800 Subject: [PATCH 8/9] Dont require score on challenge recaptcha --- packages/api/src/utils/recaptcha.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/api/src/utils/recaptcha.ts b/packages/api/src/utils/recaptcha.ts index 2fa69a626..b594f8f53 100644 --- a/packages/api/src/utils/recaptcha.ts +++ b/packages/api/src/utils/recaptcha.ts @@ -8,12 +8,7 @@ type RecaptchaResponse = { } const isRecaptchaResponse = (data: any): data is RecaptchaResponse => { - return ( - 'success' in data && - 'hostname' in data && - 'score' in data && - 'action' in data - ) + return 'success' in data && 'hostname' in data } export const verifyChallengeRecaptcha = async ( From 23f5377b62bad06cc1f1db7ca7ce8999ce4dcdbc Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 1 Apr 2024 12:03:36 +0800 Subject: [PATCH 9/9] Clean up isRecaptchaResponse function --- packages/api/src/utils/recaptcha.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/api/src/utils/recaptcha.ts b/packages/api/src/utils/recaptcha.ts index b594f8f53..2fabdfc57 100644 --- a/packages/api/src/utils/recaptcha.ts +++ b/packages/api/src/utils/recaptcha.ts @@ -1,19 +1,19 @@ import axios from 'axios' type RecaptchaResponse = { - success: Boolean + success: boolean hostname: string score?: number action?: string } -const isRecaptchaResponse = (data: any): data is RecaptchaResponse => { +export const isRecaptchaResponse = (data: any): data is RecaptchaResponse => { return 'success' in data && 'hostname' in data } export const verifyChallengeRecaptcha = async ( token: string -): Promise => { +): Promise => { if (!process.env.RECAPTCHA_CHALLENGE_SECRET_KEY) { return false } @@ -26,17 +26,12 @@ export const verifyChallengeRecaptcha = async ( try { const response = await axios.post(url, params) - console.log('recaptcha response: ', response) + console.log('recaptcha response: ', response.data) - if (!response.data || !response.data.success) { - throw new Error('Failed to verify reCAPTCHA') + if (isRecaptchaResponse(response.data)) { + return response.data.success } - - const json = response.data - if (!isRecaptchaResponse(json)) { - return false - } - return json.success + return false } catch (error) { console.error('Error verifying reCAPTCHA:', error) return false