Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions docs/content/rctf/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,18 @@ Important values to configure to customize your CTF.

Optional configuration to enable additional features.

| YAML/JSON name | environment name | required | default value | type | description |
| ---------------------- | ---------------------------- | -------- | ------------- | -------- | ------------------------------------------------------------------------------ |
| `sponsors` | _(none)_ | yes | `[]` | array | list of CTF sponsors. [documentation](management/home.md) |
| `globalSiteTag` | `RCTF_GLOBAL_SITE_TAG` | no | _(none)_ | string | Google Analytics site tag |
| `email.provider` | _(none)_ | no | _(none)_ | provider | provider for email sending. [documentation](providers/emails/index.md) |
| `email.from` | _(none)_ | no | _(none)_ | provider | `from:` address when sending email. [documentation](providers/emails/index.md) |
| `email.logoUrl` | `RCTF_EMAIL_LOGO_URL` | no | _(none)_ | string | URL to raster image of the CTF's logo |
| `ctftime.clientId` | `RCTF_CTFTIME_CLIENT_ID` | no | _(none)_ | string | CTFtime OAuth client ID. [documentation](integrations/ctftime.md) |
| `ctftime.clientSecret` | `RCTF_CTFTIME_CLIENT_SECRET` | no | _(none)_ | string | CTFtime OAuth client secret. [documentation](integrations/ctftime.md) |
| YAML/JSON name | environment name | required | default value | type | description |
| ---------------------------- | ---------------------------------- | -------- | ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- |
| `sponsors` | _(none)_ | yes | `[]` | array | list of CTF sponsors. [documentation](management/home.md) |
| `globalSiteTag` | `RCTF_GLOBAL_SITE_TAG` | no | _(none)_ | string | Google Analytics site tag |
| `email.provider` | _(none)_ | no | _(none)_ | provider | provider for email sending. [documentation](providers/emails/index.md) |
| `email.from` | _(none)_ | no | _(none)_ | provider | `from:` address when sending email. [documentation](providers/emails/index.md) |
| `email.logoUrl` | `RCTF_EMAIL_LOGO_URL` | no | _(none)_ | string | URL to raster image of the CTF's logo |
| `ctftime.clientId` | `RCTF_CTFTIME_CLIENT_ID` | no | _(none)_ | string | CTFtime OAuth client ID. [documentation](integrations/ctftime.md) |
| `ctftime.clientSecret` | `RCTF_CTFTIME_CLIENT_SECRET` | no | _(none)_ | string | CTFtime OAuth client secret. [documentation](integrations/ctftime.md) |
| `recaptcha.siteKey` | `RCTF_RECAPTCHA_SITE_KEY` | no | _(none)_ | string | reCAPTCHA public site key. [documentation](integrations/recaptcha.md) |
| `recaptcha.secretKey` | `RCTF_RECAPTCHA_SECRET_KEY` | no | _(none)_ | string | reCAPTCHA secret key. [documentation](integrations/recaptcha.md) |
| `recaptcha.protectedActions` | `RCTF_RECAPTCHA_PROTECTED_ACTIONS` | no | _(none)_ | array | list of actions protected by reCAPTCHA (`register`, `recover`, `setEmail`). [documentation](integrations/recaptcha.md) |

### Advanced

Expand Down
25 changes: 25 additions & 0 deletions docs/content/rctf/integrations/recaptcha.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# reCAPTCHA

To protect sensitive actions from abuse, configure Google reCAPTCHA v2 (invisible).
You can [register for credentials here](https://www.google.com/recaptcha/admin/create).

Copy the values from Google into `recaptcha.siteKey` and `recaptcha.secretKey` (or
`RCTF_RECAPTCHA_SITE_KEY` and `RCTF_RECAPTCHA_SECRET_KEY`).

You must also set `recaptcha.protectedActions` to a list of actions that require a
token. Valid values are:

- `register`
- `recover`
- `setEmail`

Example configuration to protect registration and auth recovery endpoints:

```yaml
recaptcha:
siteKey: AAA
secretKey: BBB
protectedActions:
- register
- recover
```
2 changes: 2 additions & 0 deletions packages/api-types/src/responses/badRecaptchaCode.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
status: 401
message: The recaptcha code is invalid.
3 changes: 3 additions & 0 deletions packages/api-types/src/routes/auth/recover/post.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ schema:
properties:
email:
type: string
recaptchaCode:
type: string
required:
- email
responses:
- goodVerifySent
- badEndpoint
- badEmail
- badUnknownEmail
- badRecaptchaCode
3 changes: 3 additions & 0 deletions packages/api-types/src/routes/auth/register/post.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ schema:
type: string
ctftimeToken:
type: string
recaptchaCode:
type: string
required:
- name
oneOf:
Expand All @@ -29,3 +31,4 @@ responses:
- badKnownEmail
- badKnownName
- badRegistrationsDisabled
- badRecaptchaCode
3 changes: 3 additions & 0 deletions packages/api-types/src/routes/users/me/auth/email/put.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ schema:
properties:
email:
type: string
recaptchaCode:
type: string
required:
- email
responses:
Expand All @@ -16,3 +18,4 @@ responses:
- badKnownEmail
- badEmailChangeDivision
- badUnknownUser
- badRecaptchaCode
14 changes: 13 additions & 1 deletion packages/client/src/components/recaptcha.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ const loadRecaptchaScript = () =>
const recaptchaQueue = []
let recaptchaPromise
let recaptchaId
let recaptchaContainer
const getRecaptchaContainer = () => {
if (recaptchaContainer) {
return recaptchaContainer
}
const container = document.createElement('div')
container.style.display = 'none'
document.body.appendChild(container)
recaptchaContainer = container
return container
}
const handleRecaptchaNext = async () => {
if (recaptchaQueue.length === 0) {
return
Expand All @@ -38,8 +49,9 @@ const loadRecaptcha = async () => {
recaptchaPromise = recaptchaPromise ?? loadRecaptchaScript()
recaptchaId =
recaptchaId ??
(await recaptchaPromise).render({
(await recaptchaPromise).render(getRecaptchaContainer(), {
theme: 'dark',
size: 'invisible',
sitekey: config.recaptcha.siteKey,
callback: handleRecaptchaDone,
'error-callback': handleRecaptchaError,
Expand Down
11 changes: 6 additions & 5 deletions packages/client/src/routes/register.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ import {
RecaptchaLegalNotice,
} from '../components/recaptcha'

// legacy check for class components
const recaptchaEnabled = config.recaptcha?.protectedActions.includes('register')

export default withStyles(
{
root: {
Expand Down Expand Up @@ -47,6 +44,9 @@ export default withStyles(
},
},
class Register extends Component {
recaptchaEnabled = () =>
config.recaptcha?.protectedActions?.includes('register')

state = {
name: '',
email: '',
Expand All @@ -59,7 +59,7 @@ export default withStyles(

componentDidMount() {
document.title = `Registration | ${config.ctfName}`
if (recaptchaEnabled) {
if (this.recaptchaEnabled()) {
loadRecaptcha()
}
}
Expand All @@ -76,6 +76,7 @@ export default withStyles(
verifySent,
}
) {
const recaptchaEnabled = this.recaptchaEnabled()
if (ctftimeToken) {
return (
<CtftimeAdditional
Expand Down Expand Up @@ -167,7 +168,7 @@ export default withStyles(
handleSubmit = async e => {
e.preventDefault()

const recaptchaCode = recaptchaEnabled
const recaptchaCode = this.recaptchaEnabled()
? await requestRecaptchaCode()
: undefined

Expand Down
15 changes: 15 additions & 0 deletions packages/server/src/api/auth/recover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,27 @@ import { getToken, tokenKinds } from '../../auth/token'
import { getUserByEmail } from '../../database/users'
import config from '../../config/server'
import { sendVerification } from '../../email'
import {
checkProtectedAction,
RecaptchaProtectedActions,
verifyRecaptchaCode,
} from '../../util/recaptcha'

const recaptchaEnabled = checkProtectedAction(RecaptchaProtectedActions.recover)

export default makeFastifyRoute(authRecoverPost, async ({ req, res }) => {
if (!config.email) {
return res.badEndpoint()
}

if (
recaptchaEnabled &&
(!req.body.recaptchaCode ||
!(await verifyRecaptchaCode(req.body.recaptchaCode)))
) {
return res.badRecaptchaCode()
}

const email = normalizeEmail(req.body.email)
if (!emailValidator.validate(email)) {
return res.badEmail()
Expand Down
12 changes: 12 additions & 0 deletions packages/server/src/api/auth/register.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,23 @@ import config from '../../config/server'
import { getUserByNameOrEmail } from '../../database/users'
import { sendVerification } from '../../email'

const recaptchaEnabled = util.recaptcha.checkProtectedAction(
util.recaptcha.RecaptchaProtectedActions.register
)

export default makeFastifyRoute(authRegisterPost, async ({ req, res }) => {
if (!config.registrationsEnabled) {
return res.badRegistrationsDisabled()
}

if (
recaptchaEnabled &&
(!req.body.recaptchaCode ||
!(await util.recaptcha.verifyRecaptchaCode(req.body.recaptchaCode)))
) {
return res.badRecaptchaCode()
}

let email
let ctftimeId
if (req.body.ctftimeToken !== undefined) {
Expand Down
17 changes: 17 additions & 0 deletions packages/server/src/api/users/me-auth/email/put.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,27 @@ import { divisionAllowed } from '../../../../util/restrict'
import { getToken, tokenKinds } from '../../../../auth/token'
import { getUserByEmail, updateUser } from '../../../../database/users'
import { sendVerification } from '../../../../email'
import {
checkProtectedAction,
RecaptchaProtectedActions,
verifyRecaptchaCode,
} from '../../../../util/recaptcha'

const recaptchaEnabled = checkProtectedAction(
RecaptchaProtectedActions.setEmail
)

export default makeFastifyRoute(
usersMeAuthEmailPut,
async ({ req, user, res }) => {
if (
recaptchaEnabled &&
(!req.body.recaptchaCode ||
!(await verifyRecaptchaCode(req.body.recaptchaCode)))
) {
return res.badRecaptchaCode()
}

const email = normalizeEmail(req.body.email)
if (!emailValidator.validate(email)) {
return res.badEmail()
Expand Down
7 changes: 7 additions & 0 deletions packages/server/src/config/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ const config: ClientConfig = {
: {
clientId: server.ctftime.clientId,
},
recaptcha:
server.recaptcha == null
? undefined
: {
siteKey: server.recaptcha.siteKey,
protectedActions: server.recaptcha.protectedActions,
},
}

export default config
10 changes: 10 additions & 0 deletions packages/server/src/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { removeUndefined } from '../util/object'
import path from 'path'
import fs from 'fs'
import yaml from 'yaml'
import type { RecaptchaProtectedActions } from '../util/recaptcha'

const isRoot = (p: string): boolean => {
const parsed = path.parse(p)
Expand Down Expand Up @@ -94,6 +95,15 @@ export const loadEnvConfig = (): PartialDeep<ServerConfig> =>
clientId: process.env.RCTF_CTFTIME_CLIENT_ID,
clientSecret: process.env.RCTF_CTFTIME_CLIENT_SECRET,
},
recaptcha: {
siteKey: process.env.RCTF_RECAPTCHA_SITE_KEY,
secretKey: process.env.RCTF_RECAPTCHA_SECRET_KEY,
protectedActions: (process.env.RCTF_RECAPTCHA_PROTECTED_ACTIONS?.split(
','
)
.map(s => s.trim())
.filter(Boolean) ?? undefined) as RecaptchaProtectedActions[],
},
userMembers: nullsafeParseBoolEnv(process.env.RCTF_USER_MEMBERS),
homeContent: process.env.RCTF_HOME_CONTENT,
ctfName: process.env.RCTF_NAME,
Expand Down
11 changes: 11 additions & 0 deletions packages/server/src/config/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { RecaptchaProtectedActions } from '../util/recaptcha'
import { ACL } from '../util/restrict'

export interface ProviderConfig {
Expand Down Expand Up @@ -48,6 +49,12 @@ export interface ServerConfig {
clientSecret: string
}

recaptcha?: {
siteKey: string
secretKey: string
protectedActions: RecaptchaProtectedActions[]
}

userMembers: boolean
registrationsEnabled: boolean

Expand Down Expand Up @@ -105,4 +112,8 @@ export type ClientConfig = Pick<
> & {
emailEnabled: boolean
ctftime?: Pick<NonNullable<ServerConfig['ctftime']>, 'clientId'>
recaptcha?: Pick<
NonNullable<ServerConfig['recaptcha']>,
'siteKey' | 'protectedActions'
>
}
1 change: 1 addition & 0 deletions packages/server/src/util/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * as normalize from './normalize'
export * as validate from './validate'
export * as scores from './scores'
export * as restrict from './restrict'
export * as recaptcha from './recaptcha'

export const serveIndex: FastifyPluginAsync<{ indexPath: string }> = async (
fastify,
Expand Down
30 changes: 30 additions & 0 deletions packages/server/src/util/recaptcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import got from 'got'
import config from '../config/server'

export enum RecaptchaProtectedActions {
register = 'register',
recover = 'recover',
setEmail = 'setEmail',
}

export const verifyRecaptchaCode = async (code: string): Promise<boolean> => {
if (!config.recaptcha) {
throw new Error('recaptcha is not configured')
}
const { body }: { body: { success: boolean } } = await got({
url: 'https://www.google.com/recaptcha/api/siteverify',
method: 'POST',
responseType: 'json',
form: {
secret: config.recaptcha.secretKey,
response: code,
},
})
return body.success
}

export const checkProtectedAction = (
action: RecaptchaProtectedActions
): boolean => {
return config.recaptcha?.protectedActions.includes(action) ?? false
}