From 73f74bbac845512e8beae21dc61c47378ddbf87a Mon Sep 17 00:00:00 2001 From: Antoine Hurard Date: Mon, 13 Jul 2026 12:43:20 +0200 Subject: [PATCH 1/2] Forms can now be marked as public --- .../routes/public/public.routes.spec.ts | 118 ++++++++++++++++++ src/models/form.model.ts | 5 + src/routes/index.ts | 3 + src/routes/public/index.ts | 45 +++++++ src/schema/mutation/editForm.mutation.ts | 2 + src/schema/types/form.type.ts | 6 + 6 files changed, 179 insertions(+) create mode 100644 __tests__/unit-tests/routes/public/public.routes.spec.ts create mode 100644 src/routes/public/index.ts diff --git a/__tests__/unit-tests/routes/public/public.routes.spec.ts b/__tests__/unit-tests/routes/public/public.routes.spec.ts new file mode 100644 index 000000000..b42221348 --- /dev/null +++ b/__tests__/unit-tests/routes/public/public.routes.spec.ts @@ -0,0 +1,118 @@ +import { status } from '@const/enumTypes'; +import { Form } from '@models'; +import publicRoutes from '@routes/public'; +import express, { NextFunction, Request, Response } from 'express'; +import mongoose from 'mongoose'; +import supertest from 'supertest'; +import { DatabaseHelpers } from '../../../helpers/database-helpers'; + +jest.mock('@services/logger.service'); + +/** + * Build a minimal express app mounting the public routes, with a stubbed + * translation function since the real i18next middleware is not loaded here. + * + * @returns Express application + */ +const buildApp = () => { + const app = express(); + app.use((req: Request, res: Response, next: NextFunction) => { + (req as any).t = (key: string) => key; + next(); + }); + app.use('/public', publicRoutes); + return app; +}; + +let databaseHelpers: DatabaseHelpers; +let request: supertest.SuperTest; +let publicForm: Form; +let privateForm: Form; + +describe('Public routes', () => { + beforeAll(async () => { + databaseHelpers = new DatabaseHelpers(); + await databaseHelpers.connect(); + request = supertest(buildApp()); + publicForm = await Form.create({ + name: 'Public form', + graphQLTypeName: 'PublicForm', + status: status.active, + isPublic: true, + structure: { pages: [] }, + fields: [{ name: 'description', type: 'text' }], + permissions: { + canSee: [new mongoose.Types.ObjectId()], + }, + }); + privateForm = await Form.create({ + name: 'Private form', + graphQLTypeName: 'PrivateForm', + status: status.active, + structure: { pages: [] }, + fields: [], + }); + }); + + afterAll(async () => { + await databaseHelpers.disconnect(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('GET /public/forms/:id', () => { + it('should return a form marked as public', async () => { + const response = await request.get(`/public/forms/${publicForm.id}`); + + expect(response.status).toBe(200); + expect(response.body._id).toEqual(publicForm.id); + expect(response.body.name).toEqual('Public form'); + expect(response.body.status).toEqual(status.active); + expect(response.body.structure).toEqual({ pages: [] }); + expect(response.body.fields).toEqual([ + { name: 'description', type: 'text' }, + ]); + }); + + it('should not expose fields outside the public whitelist', async () => { + const response = await request.get(`/public/forms/${publicForm.id}`); + + expect(response.status).toBe(200); + expect(response.body.permissions).toBeUndefined(); + expect(response.body.isPublic).toBeUndefined(); + expect(response.body.graphQLTypeName).toBeUndefined(); + }); + + it('should return 404 for a form not marked as public', async () => { + const response = await request.get(`/public/forms/${privateForm.id}`); + + expect(response.status).toBe(404); + }); + + it('should return 404 for a non-existing form', async () => { + const response = await request.get( + `/public/forms/${new mongoose.Types.ObjectId()}` + ); + + expect(response.status).toBe(404); + }); + + it('should return 404 for an invalid form id', async () => { + const response = await request.get('/public/forms/not-an-object-id'); + + expect(response.status).toBe(404); + }); + + it('should return 500 if the database query fails', async () => { + jest.spyOn(Form, 'findOne').mockImplementationOnce(() => { + throw new Error('Database error'); + }); + + const response = await request.get(`/public/forms/${publicForm.id}`); + + expect(response.status).toBe(500); + }); + }); +}); diff --git a/src/models/form.model.ts b/src/models/form.model.ts index 1972fa6a1..63515c725 100644 --- a/src/models/form.model.ts +++ b/src/models/form.model.ts @@ -20,6 +20,7 @@ interface FormDocument extends Document { modifiedAt?: Date; structure?: any; core?: boolean; + isPublic?: boolean; status?: string; permissions?: { canSee?: any[]; @@ -54,6 +55,10 @@ const schema = new Schema
( graphQLTypeName: String, structure: mongoose.Schema.Types.Mixed, core: Boolean, + isPublic: { + type: Boolean, + default: false, + }, status: { type: String, enum: Object.values(status), diff --git a/src/routes/index.ts b/src/routes/index.ts index f0747fe98..890fa08dd 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -11,6 +11,7 @@ import roles from './roles'; import gis from './gis'; import style from './style'; import notification from './notification'; +import publicRoutes from './public'; import config from 'config'; import { RouteDefinition } from 'types/route-definition'; import { logger } from '@services/logger.service'; @@ -71,6 +72,8 @@ export default function registerRoutes(): Router | undefined { if (config.get('server.rateLimit.enable')) { router.use(rateLimitMiddleware); } + // Public routes, mounted before restMiddleware so no authentication is required + router.use('/public', publicRoutes); router.use(restMiddleware); router.use('/download', download); router.use('/proxy', proxy); diff --git a/src/routes/public/index.ts b/src/routes/public/index.ts new file mode 100644 index 000000000..1caf31a3d --- /dev/null +++ b/src/routes/public/index.ts @@ -0,0 +1,45 @@ +import express from 'express'; +import { Form } from '@models'; +import { logger } from '@services/logger.service'; +import { getErrorMessage, getErrorStack } from '@utils/error'; +import mongoose from 'mongoose'; + +/** + * Routes accessible without authentication. + * Only exposes content explicitly marked as public. + */ +const router = express.Router(); + +/** Form fields exposed on public endpoints, permissions excluded */ +const PUBLIC_FORM_FIELDS = [ + 'name', + 'structure', + 'fields', + 'status', + 'createdAt', + 'modifiedAt', +].join(' '); + +/** + * Get a single form by id, if marked as public. + */ +router.get('/forms/:id', async (req, res) => { + try { + if (!mongoose.Types.ObjectId.isValid(req.params.id)) { + return res.status(404).send(req.t('common.errors.dataNotFound')); + } + const form = await Form.findOne({ + _id: req.params.id, + isPublic: true, + }).select(PUBLIC_FORM_FIELDS); + if (!form) { + return res.status(404).send(req.t('common.errors.dataNotFound')); + } + return res.status(200).send(form); + } catch (err) { + logger.error(getErrorMessage(err), { stack: getErrorStack(err) }); + return res.status(500).send(req.t('common.errors.internalServerError')); + } +}); + +export default router; diff --git a/src/schema/mutation/editForm.mutation.ts b/src/schema/mutation/editForm.mutation.ts index 9687ef51d..2f0a3929e 100644 --- a/src/schema/mutation/editForm.mutation.ts +++ b/src/schema/mutation/editForm.mutation.ts @@ -213,6 +213,8 @@ export default { if (args.structure && !isEqual(form.structure, args.structure)) { update.structure = args.structure; const structure = JSON.parse(args.structure); + // Public status is defined in the form definition itself, extract it on save + update.isPublic = structure?.isPublic === true; const fields = []; const pages = structure && Array.isArray(structure.pages) ? structure.pages : []; diff --git a/src/schema/types/form.type.ts b/src/schema/types/form.type.ts index 9911c6053..9649fdcd2 100644 --- a/src/schema/types/form.type.ts +++ b/src/schema/types/form.type.ts @@ -72,6 +72,12 @@ export const FormType = new GraphQLObjectType({ return parent.core ? parent.core : false; }, }, + isPublic: { + type: GraphQLBoolean, + resolve(parent) { + return parent.isPublic ? parent.isPublic : false; + }, + }, records: { type: RecordConnectionType, args: { From b2bb092349f31a512bce6b01fa9d0381c5a38505 Mon Sep 17 00:00:00 2001 From: Antoine Hurard Date: Wed, 15 Jul 2026 20:19:58 +0200 Subject: [PATCH 2/2] Implement Captcha --- .../mutation/addRecord.mutation.spec.ts | 204 ++++++++++++++++++ .../captcha/verifyTurnstileToken.spec.ts | 70 ++++++ config/custom-environment-variables.js | 5 + config/default.js | 7 + src/i18n/en.json | 1 + src/i18n/test.json | 1 + src/i18n/uk.json | 1 + src/schema/mutation/addRecord.mutation.ts | 84 +++++--- src/utils/captcha/index.ts | 1 + src/utils/captcha/verifyTurnstileToken.ts | 60 ++++++ 10 files changed, 407 insertions(+), 27 deletions(-) create mode 100644 __tests__/unit-tests/schema/mutation/addRecord.mutation.spec.ts create mode 100644 __tests__/unit-tests/utils/captcha/verifyTurnstileToken.spec.ts create mode 100644 src/utils/captcha/index.ts create mode 100644 src/utils/captcha/verifyTurnstileToken.ts diff --git a/__tests__/unit-tests/schema/mutation/addRecord.mutation.spec.ts b/__tests__/unit-tests/schema/mutation/addRecord.mutation.spec.ts new file mode 100644 index 000000000..e584f93d9 --- /dev/null +++ b/__tests__/unit-tests/schema/mutation/addRecord.mutation.spec.ts @@ -0,0 +1,204 @@ +import { Form, Record } from '@models'; +import addRecord, { + AddRecordArgs, +} from '@schema/mutation/addRecord.mutation'; +import { Types } from 'mongoose'; +import { DatabaseHelpers } from '../../../helpers/database-helpers'; +import { GraphQLError } from 'graphql'; +import { Context } from '@server/apollo/context'; +import { logger } from '@services/logger.service'; +import extendAbilityForRecords from '@security/extendAbilityForRecords'; +import { verifyTurnstileToken } from '@utils/captcha'; +import { getNextId } from '@utils/form'; + +jest.mock('@services/logger.service'); + +// Mock the extendAbilityForRecords function +jest.mock('@security/extendAbilityForRecords', () => ({ + __esModule: true, + default: jest.fn(), +})); + +// Mock the captcha verification, so no call is made to Cloudflare +jest.mock('@utils/captcha', () => ({ + __esModule: true, + verifyTurnstileToken: jest.fn(), +})); + +// Mock getNextId only, as it relies on Redis +jest.mock('@utils/form', () => ({ + ...jest.requireActual('@utils/form'), + getNextId: jest.fn(), +})); + +describe('addRecord Resolver', () => { + let context: Context; + let args: AddRecordArgs; + let databaseHelpers: DatabaseHelpers; + let publicForm: Form; + let privateForm: Form; + let nextIdCounter = 0; + + beforeAll(async () => { + databaseHelpers = new DatabaseHelpers(); + await databaseHelpers.connect(); + publicForm = await Form.create({ + name: 'Public form', + graphQLTypeName: 'PublicForm', + isPublic: true, + fields: [{ name: 'description', type: 'text' }], + }); + privateForm = await Form.create({ + name: 'Private form', + graphQLTypeName: 'PrivateForm', + fields: [{ name: 'description', type: 'text' }], + }); + }); + + afterAll(async () => { + await databaseHelpers.disconnect(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + context = { + user: { + _id: new Types.ObjectId(), + name: 'Test User', + username: 'test@user.com', + roles: [{ _id: new Types.ObjectId() }], + positionAttributes: [], + ability: { can: jest.fn().mockReturnValue(true) }, + }, + i18next: { t: jest.fn((key: string) => key) }, + timeZone: 'UTC', + } as unknown as Context; + + args = { + form: privateForm.id, + data: { description: 'test record' }, + }; + + (extendAbilityForRecords as jest.Mock).mockResolvedValue({ + can: jest.fn().mockReturnValue(true), + cannot: jest.fn().mockReturnValue(false), + }); + (verifyTurnstileToken as jest.Mock).mockResolvedValue(true); + // Records have a unique index on incrementalId, so each call must + // return a different id, as the real implementation does + (getNextId as jest.Mock).mockImplementation(async () => { + nextIdCounter += 1; + return `2026-P${String(nextIdCounter).padStart(8, '0')}`; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('Authenticated user', () => { + it('should create a record if the user has permission', async () => { + const record = await addRecord.resolve(null, args, context); + expect(record).toBeInstanceOf(Record); + expect(record.createdBy.user).toEqual(context.user._id); + expect(record._createdBy.user.username).toEqual('test@user.com'); + expect(record.incrementalId).toMatch(/^2026-P\d{8}$/); + expect(record.data.description).toEqual('test record'); + }); + + it('should not require a captcha token', async () => { + await addRecord.resolve(null, args, context); + expect(verifyTurnstileToken).not.toHaveBeenCalled(); + }); + + it('should throw an error if the user does not have permission', async () => { + (extendAbilityForRecords as jest.Mock).mockResolvedValue({ + can: jest.fn().mockReturnValue(false), + cannot: jest.fn().mockReturnValue(true), + }); + const result = addRecord.resolve(null, args, context); + await expect(result).rejects.toThrow(GraphQLError); + expect(context.i18next.t).toHaveBeenCalledWith( + 'common.errors.permissionNotGranted' + ); + }); + + it('should throw an error if the form is not found', async () => { + args.form = new Types.ObjectId().toHexString(); + const result = addRecord.resolve(null, args, context); + await expect(result).rejects.toThrow(GraphQLError); + expect(context.i18next.t).toHaveBeenCalledWith( + 'common.errors.dataNotFound' + ); + }); + }); + + describe('Unauthenticated user', () => { + beforeEach(() => { + context = { ...context, user: null } as unknown as Context; + args = { + form: publicForm.id, + data: { description: 'public record' }, + captchaToken: 'captcha-token', + }; + }); + + it('should throw an error if the form is not public', async () => { + args.form = privateForm.id; + const result = addRecord.resolve(null, args, context); + await expect(result).rejects.toThrow(GraphQLError); + expect(context.i18next.t).toHaveBeenCalledWith( + 'common.errors.userNotLogged' + ); + expect(verifyTurnstileToken).not.toHaveBeenCalled(); + }); + + it('should throw an error if no captcha token is provided', async () => { + args.captchaToken = undefined; + const result = addRecord.resolve(null, args, context); + await expect(result).rejects.toThrow(GraphQLError); + expect(context.i18next.t).toHaveBeenCalledWith( + 'common.errors.invalidCaptcha' + ); + expect(verifyTurnstileToken).not.toHaveBeenCalled(); + }); + + it('should throw an error if the captcha token is invalid', async () => { + (verifyTurnstileToken as jest.Mock).mockResolvedValue(false); + const result = addRecord.resolve(null, args, context); + await expect(result).rejects.toThrow(GraphQLError); + expect(verifyTurnstileToken).toHaveBeenCalledWith('captcha-token'); + expect(context.i18next.t).toHaveBeenCalledWith( + 'common.errors.invalidCaptcha' + ); + }); + + it('should create a record on a public form with a valid captcha token', async () => { + const record = await addRecord.resolve(null, args, context); + expect(record).toBeInstanceOf(Record); + expect(verifyTurnstileToken).toHaveBeenCalledWith('captcha-token'); + expect(record.createdBy?.user).toBeUndefined(); + expect(record._createdBy?.user).toBeUndefined(); + expect(record.data.description).toEqual('public record'); + }); + + it('should skip the ability check', async () => { + await addRecord.resolve(null, args, context); + expect(extendAbilityForRecords).not.toHaveBeenCalled(); + }); + }); + + describe('Error Handling', () => { + it('should log the error and throw GraphQLError on unexpected errors', async () => { + jest + .spyOn(Record.prototype, 'save') + .mockRejectedValue(new Error('unexpected error')); + const result = addRecord.resolve(null, args, context); + await expect(result).rejects.toThrow(GraphQLError); + expect(logger.error).toHaveBeenCalled(); + expect(context.i18next.t).toHaveBeenCalledWith( + 'common.errors.internalServerError' + ); + }); + }); +}); diff --git a/__tests__/unit-tests/utils/captcha/verifyTurnstileToken.spec.ts b/__tests__/unit-tests/utils/captcha/verifyTurnstileToken.spec.ts new file mode 100644 index 000000000..e5b8e81ff --- /dev/null +++ b/__tests__/unit-tests/utils/captcha/verifyTurnstileToken.spec.ts @@ -0,0 +1,70 @@ +import axios from 'axios'; +import config from 'config'; +import { verifyTurnstileToken } from '@utils/captcha'; +import { logger } from '@services/logger.service'; + +jest.mock('axios'); +jest.mock('@services/logger.service'); + +const mockedAxios = axios as jest.Mocked; + +describe('verifyTurnstileToken', () => { + let configGetSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + configGetSpy = jest.spyOn(config, 'get').mockReturnValue('test-secret'); + }); + + afterEach(() => { + configGetSpy.mockRestore(); + }); + + it('should return false and skip the API call if the secret is not configured', async () => { + configGetSpy.mockReturnValue(''); + const result = await verifyTurnstileToken('some-token'); + expect(result).toBe(false); + expect(mockedAxios.post).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); + }); + + it('should return true when the verification succeeds', async () => { + mockedAxios.post.mockResolvedValue({ data: { success: true } }); + const result = await verifyTurnstileToken('valid-token'); + expect(result).toBe(true); + expect(mockedAxios.post).toHaveBeenCalledWith( + 'https://challenges.cloudflare.com/turnstile/v0/siteverify', + { secret: 'test-secret', response: 'valid-token' }, + expect.objectContaining({ timeout: expect.any(Number) }) + ); + }); + + it('should include the remote ip in the verification request when provided', async () => { + mockedAxios.post.mockResolvedValue({ data: { success: true } }); + await verifyTurnstileToken('valid-token', '1.2.3.4'); + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + { + secret: 'test-secret', + response: 'valid-token', + remoteip: '1.2.3.4', + }, + expect.anything() + ); + }); + + it('should return false when the verification fails', async () => { + mockedAxios.post.mockResolvedValue({ + data: { success: false, 'error-codes': ['invalid-input-response'] }, + }); + const result = await verifyTurnstileToken('invalid-token'); + expect(result).toBe(false); + }); + + it('should return false when the verification request throws', async () => { + mockedAxios.post.mockRejectedValue(new Error('network error')); + const result = await verifyTurnstileToken('valid-token'); + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalled(); + }); +}); diff --git a/config/custom-environment-variables.js b/config/custom-environment-variables.js index 66abd91a4..99ee38321 100644 --- a/config/custom-environment-variables.js +++ b/config/custom-environment-variables.js @@ -87,4 +87,9 @@ module.exports = { publicStorage: { url: 'PUBLIC_STORAGE_URL', }, + captcha: { + turnstile: { + secret: 'CAPTCHA_TURNSTILE_SECRET', + }, + }, }; diff --git a/config/default.js b/config/default.js index a54aac876..c7ac43f6e 100644 --- a/config/default.js +++ b/config/default.js @@ -197,4 +197,11 @@ module.exports = { url: '', enable: false, }, + captcha: { + turnstile: { + // Cloudflare Turnstile secret key, used to verify captcha tokens + // sent by unauthenticated users on public forms. + secret: '', + }, + }, }; diff --git a/src/i18n/en.json b/src/i18n/en.json index 3875c0ea1..ce74c8e05 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -12,6 +12,7 @@ "fileTotalSizeLimitReached": "Total File size exceed 7MB", "internalServerError": "Internal Server Error", "invalidAPI": "API cannot be reached.", + "invalidCaptcha": "Invalid captcha. Please try again.", "invalidEmailsInput": "Wrong format detected. Please provide valid emails.", "invalidGraphQLName": "The name can only consist of alphanumeric characters and underscores, and must start with a letter. Please choose a different name.", "maximumPaginationLimit": "Maximum page size is {{paginationLimit}}. Please use a different page size.", diff --git a/src/i18n/test.json b/src/i18n/test.json index a27addbcd..0f05a4276 100644 --- a/src/i18n/test.json +++ b/src/i18n/test.json @@ -12,6 +12,7 @@ "fileTotalSizeLimitReached": "******", "internalServerError": "******", "invalidAPI": "******", + "invalidCaptcha": "******", "invalidEmailsInput": "******", "invalidGraphQLName": "******", "maximumPaginationLimit": "****** {{paginationLimit}} ******", diff --git a/src/i18n/uk.json b/src/i18n/uk.json index b12648c6a..f8ed9e5df 100644 --- a/src/i18n/uk.json +++ b/src/i18n/uk.json @@ -12,6 +12,7 @@ "fileTotalSizeLimitReached": "Загальний розмір файлів перевищує 7 МБ", "internalServerError": "Внутрішня помилка сервера", "invalidAPI": "API недоступне.", + "invalidCaptcha": "Недійсна капча. Будь ласка, спробуйте ще раз.", "invalidEmailsInput": "Виявлено неправильний формат. Будь ласка, вкажіть дійсні адреси електронної пошти.", "invalidGraphQLName": "Ім'я може складатися лише з буквено-цифрових символів та підкреслень і має починатися з літери. Будь ласка, виберіть інше Ім'я.", "maximumPaginationLimit": "Максимальний розмір сторінки: {{paginationLimit}}. Будь ласка, використовуйте інший розмір сторінки.", diff --git a/src/schema/mutation/addRecord.mutation.ts b/src/schema/mutation/addRecord.mutation.ts index 3749702ee..ca36fc86d 100644 --- a/src/schema/mutation/addRecord.mutation.ts +++ b/src/schema/mutation/addRecord.mutation.ts @@ -1,4 +1,9 @@ -import { GraphQLID, GraphQLNonNull, GraphQLError } from 'graphql'; +import { + GraphQLID, + GraphQLNonNull, + GraphQLError, + GraphQLString, +} from 'graphql'; import GraphQLJSON from 'graphql-type-json'; import { RecordType } from '../types'; import { Form, Record, Notification, Channel } from '@models'; @@ -7,19 +12,23 @@ import extendAbilityForRecords from '@security/extendAbilityForRecords'; import pubsub from '../../server/pubsub'; import { getFormPermissionFilter } from '@utils/filter'; import { logger } from '@services/logger.service'; -import { graphQLAuthCheck } from '@schema/shared'; +import { verifyTurnstileToken } from '@utils/captcha'; import { Types } from 'mongoose'; import { Context } from '@server/apollo/context'; import { getErrorMessage, getErrorStack } from '@utils/error'; /** Arguments for the addRecord mutation */ -type AddRecordArgs = { +export type AddRecordArgs = { form?: string | Types.ObjectId; data: any; + captchaToken?: string; }; /** * Add a record to a form, if user authorized. + * Unauthenticated users can add records to public forms, provided they pass + * a valid Cloudflare Turnstile captcha token. In that case, the ability check + * is skipped. * Throw a GraphQL error if not logged or authorized, or form not found. * TODO: we have to check form by form for that. */ @@ -28,9 +37,9 @@ export default { args: { form: { type: GraphQLID }, data: { type: new GraphQLNonNull(GraphQLJSON) }, + captchaToken: { type: GraphQLString }, }, async resolve(parent, args: AddRecordArgs, context: Context) { - graphQLAuthCheck(context); try { const user = context.user; @@ -39,16 +48,35 @@ export default { if (!form) throw new GraphQLError(context.i18next.t('common.errors.dataNotFound')); - // Check the ability with permissions for this form - const ability = await extendAbilityForRecords(user, form); - if (ability.cannot('create', 'Record')) { - throw new GraphQLError( - context.i18next.t('common.errors.permissionNotGranted') - ); + if (user) { + // Check the ability with permissions for this form + const ability = await extendAbilityForRecords(user, form); + if (ability.cannot('create', 'Record')) { + throw new GraphQLError( + context.i18next.t('common.errors.permissionNotGranted') + ); + } + } else { + // Unauthenticated users can only add records to public forms + if (!form.isPublic) { + throw new GraphQLError( + context.i18next.t('common.errors.userNotLogged') + ); + } + // Captcha verification replaces the ability check + if ( + !args.captchaToken || + !(await verifyTurnstileToken(args.captchaToken)) + ) { + throw new GraphQLError( + context.i18next.t('common.errors.invalidCaptcha') + ); + } } // Check unicity of record if ( + user && form.permissions.recordsUnicity && form.permissions.recordsUnicity.length > 0 && form.permissions.recordsUnicity[0].role @@ -84,24 +112,26 @@ export default { //modifiedAt: new Date(), data: args.data, resource: form.resource ? form.resource : null, - createdBy: { - user: user._id, - roles: user.roles.map((x) => x._id), - positionAttributes: user.positionAttributes.map((x) => { - return { - value: x.value, - category: x.category._id, - }; - }), - }, - lastUpdateForm: form.id, - _createdBy: { - user: { - _id: context.user._id, - name: context.user.name, - username: context.user.username, + ...(user && { + createdBy: { + user: user._id, + roles: user.roles.map((x) => x._id), + positionAttributes: user.positionAttributes.map((x) => { + return { + value: x.value, + category: x.category._id, + }; + }), }, - }, + _createdBy: { + user: { + _id: user._id, + name: user.name, + username: user.username, + }, + }, + }), + lastUpdateForm: form.id, _form: { _id: form._id, name: form.name, diff --git a/src/utils/captcha/index.ts b/src/utils/captcha/index.ts new file mode 100644 index 000000000..602d2e48a --- /dev/null +++ b/src/utils/captcha/index.ts @@ -0,0 +1 @@ +export * from './verifyTurnstileToken'; diff --git a/src/utils/captcha/verifyTurnstileToken.ts b/src/utils/captcha/verifyTurnstileToken.ts new file mode 100644 index 000000000..8e0e118f2 --- /dev/null +++ b/src/utils/captcha/verifyTurnstileToken.ts @@ -0,0 +1,60 @@ +import axios from 'axios'; +import config from 'config'; +import { logger } from '@services/logger.service'; +import { getErrorMessage, getErrorStack } from '@utils/error'; + +/** Cloudflare Turnstile token verification endpoint */ +const TURNSTILE_VERIFY_URL = + 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + +/** Response of the Turnstile siteverify endpoint */ +interface TurnstileVerifyResponse { + success: boolean; + 'error-codes'?: string[]; +} + +/** + * Verify a Cloudflare Turnstile captcha token. + * Fails closed: returns false if the secret is not configured or the + * verification request fails. + * + * @param token Turnstile token generated by the client widget + * @param remoteIp IP address of the visitor, if available + * @returns whether the token is valid + */ +export const verifyTurnstileToken = async ( + token: string, + remoteIp?: string +): Promise => { + const secret = config.get('captcha.turnstile.secret'); + if (!secret) { + logger.error( + 'Turnstile captcha verification requested but captcha.turnstile.secret is not configured' + ); + return false; + } + try { + const { data } = await axios.post( + TURNSTILE_VERIFY_URL, + { + secret, + response: token, + ...(remoteIp && { remoteip: remoteIp }), + }, + { timeout: 10000 } + ); + if (!data.success) { + logger.info( + `Turnstile captcha verification failed: ${( + data['error-codes'] || [] + ).join(', ')}` + ); + } + return data.success === true; + } catch (err) { + logger.error(getErrorMessage(err), { stack: getErrorStack(err) }); + return false; + } +}; + +export default verifyTurnstileToken;