diff --git a/package-lock.json b/package-lock.json index ba53d151b6..bc0ec6e6b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14645,6 +14645,20 @@ "node": ">= 0.10.0" } }, + "node_modules/express-rate-limit": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.3.1.tgz", + "integrity": "sha512-BbaryvkY4wEgDqLgD18/NSy2lDO2jTuT9Y8c1Mpx0X63Yz0sYd5zN6KPe7UvpuSVvV33T6RaE1o1IVZQjHMYgw==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "4 || 5 || ^5.0.0-beta.1" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -27905,7 +27919,6 @@ "license": "Apache-2.0", "dependencies": { "@polkadot/keyring": "12.6.2", - "@polkadot/types": "10.13.1", "@polkadot/util": "12.6.2", "@polkadot/util-crypto": "12.6.2", "@prosopo/captcha-contract": "1.0.2", @@ -27918,6 +27931,7 @@ "cors": "^2.8.5", "cron-parser": "^4.9.0", "dotenv": "^16.0.1", + "express-rate-limit": "^7.3.1", "yargs": "^17.7.2", "zod": "^3.22.4" }, diff --git a/package.json b/package.json index c0ad6d0fc2..d4143a331c 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "lint:cmd": "f() { npm run -w @prosopo/scripts license; npm run eslint:cmd -- $@; npm run prettier:cmd -- $@; }; f", "lint:workspace": "npm run eslint:workspace && npm run prettier:workspace", "lint:contracts": "npm -w @prosopo/captcha-contract -w @prosopo/common-contract -w @prosopo/proxy-contract run lint", - "lint:fix": "FILES=$(git diff --name-status main | sed '/^[M]/!D' | awk -F ' ' '{print $2}'); echo $FILES; f() { npm run -w @prosopo/scripts license:fix; npm run eslint:fix -- $FILES; npm run prettier:fix -- $FILES; }; f", + "lint:fix": "FILES=$(git diff --name-status main | sed '/^[M|A]/!D' | awk -F ' ' '{print $2}'); echo $FILES; f() { npm run -w @prosopo/scripts license:fix; npm run eslint:fix -- $FILES; npm run prettier:fix -- $FILES; }; f", "lint:fix:contracts": "npm run -w @prosopo/scripts license:fix && npm -w @prosopo/captcha-contract -w @prosopo/common-contract -w @prosopo/proxy-contract run lint:fix", "lint:fix:workspace": "npm run eslint:fix:workspace && npm run prettier:fix:workspace", "removePolkadotJSWarnings": "sed -i 's/console.warn\\(.*\\);//g' ./node_modules/@polkadot/util/versionDetect.js && sed -i 's/console.warn\\(.*\\);//g' ./node_modules/@polkadot/util/cjs/versionDetect.js || true", diff --git a/packages/cli/package.json b/packages/cli/package.json index 84f4a4a500..ccd44a1429 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -32,7 +32,6 @@ }, "dependencies": { "@polkadot/keyring": "12.6.2", - "@polkadot/types": "10.13.1", "@polkadot/util": "12.6.2", "@polkadot/util-crypto": "12.6.2", "@prosopo/captcha-contract": "1.0.2", @@ -45,18 +44,19 @@ "cors": "^2.8.5", "cron-parser": "^4.9.0", "dotenv": "^16.0.1", + "express-rate-limit": "^7.3.1", "yargs": "^17.7.2", "zod": "^3.22.4" }, "devDependencies": { - "es-main": "^1.2.0", - "express": "^4.18.2", - "vite": "^5.1.7", - "vitest": "^1.3.1", "@prosopo/config": "1.0.2", "@types/cors": "^2.8.14", + "es-main": "^1.2.0", + "express": "^4.18.2", "tslib": "2.6.2", - "typescript": "5.1.6" + "typescript": "5.1.6", + "vite": "^5.1.7", + "vitest": "^1.3.1" }, "author": "Prosopo", "license": "Apache-2.0", diff --git a/packages/cli/src/RateLimiter.ts b/packages/cli/src/RateLimiter.ts new file mode 100644 index 0000000000..eb7a9d9135 --- /dev/null +++ b/packages/cli/src/RateLimiter.ts @@ -0,0 +1,75 @@ +// Copyright 2021-2024 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { AdminApiPaths, ApiPaths } from '@prosopo/types' + +export const getRateLimitConfig = () => { + return { + [ApiPaths.GetImageCaptchaChallenge]: { + windowMs: process.env.PROSOPO_GET_IMAGE_CAPTCHA_CHALLENGE_WINDOW, + limit: process.env.PROSOPO_GET_IMAGE_CAPTCHA_CHALLENGE_LIMIT, + }, + [ApiPaths.GetPowCaptchaChallenge]: { + windowMs: process.env.PROSOPO_GET_POW_CAPTCHA_CHALLENGE_WINDOW, + limit: process.env.PROSOPO_GET_POW_CAPTCHA_CHALLENGE_LIMIT, + }, + [ApiPaths.SubmitImageCaptchaSolution]: { + windowMs: process.env.PROSOPO_SUBMIT_IMAGE_CAPTCHA_SOLUTION_WINDOW, + limit: process.env.PROSOPO_SUBMIT_IMAGE_CAPTCHA_SOLUTION_LIMIT, + }, + [ApiPaths.SubmitPowCaptchaSolution]: { + windowMs: process.env.PROSOPO_SUBMIT_POW_CAPTCHA_SOLUTION_WINDOW, + limit: process.env.PROSOPO_SUBMIT_POW_CAPTCHA_SOLUTION_LIMIT, + }, + [ApiPaths.VerifyPowCaptchaSolution]: { + windowMs: process.env.PROSOPO_VERIFY_POW_CAPTCHA_SOLUTION_WINDOW, + limit: process.env.PROSOPO_VERIFY_POW_CAPTCHA_SOLUTION_LIMIT, + }, + [ApiPaths.VerifyImageCaptchaSolutionDapp]: { + windowMs: process.env.PROSOPO_VERIFY_IMAGE_CAPTCHA_SOLUTION_DAPP_WINDOW, + limit: process.env.PROSOPO_VERIFY_IMAGE_CAPTCHA_SOLUTION_DAPP_LIMIT, + }, + [ApiPaths.VerifyImageCaptchaSolutionUser]: { + windowMs: process.env.PROSOPO_VERIFY_IMAGE_CAPTCHA_SOLUTION_USER_WINDOW, + limit: process.env.PROSOPO_VERIFY_IMAGE_CAPTCHA_SOLUTION_USER_LIMIT, + }, + [ApiPaths.GetProviderStatus]: { + windowMs: process.env.PROSOPO_GET_PROVIDER_STATUS_WINDOW, + limit: process.env.PROSOPO_GET_PROVIDER_STATUS_LIMIT, + }, + [ApiPaths.GetProviderDetails]: { + windowMs: process.env.PROSOPO_GET_PROVIDER_DETAILS_WINDOW, + limit: process.env.PROSOPO_GET_PROVIDER_DETAILS_LIMIT, + }, + [ApiPaths.SubmitUserEvents]: { + windowMs: process.env.PROSOPO_SUBMIT_USER_EVENTS_WINDOW, + limit: process.env.PROSOPO_SUBMIT_USER_EVENTS_LIMIT, + }, + [AdminApiPaths.BatchCommit]: { + windowMs: process.env.PROSOPO_BATCH_COMMIT_WINDOW, + limit: process.env.PROSOPO_BATCH_COMMIT_LIMIT, + }, + [AdminApiPaths.UpdateDataset]: { + windowMs: process.env.PROSOPO_UPDATE_DATASET_WINDOW, + limit: process.env.PROSOPO_UPDATE_DATASET_LIMIT, + }, + [AdminApiPaths.ProviderDeregister]: { + windowMs: process.env.PROSOPO_PROVIDER_DEREGISTER_WINDOW, + limit: process.env.PROSOPO_PROVIDER_DEREGISTER_LIMIT, + }, + [AdminApiPaths.ProviderUpdate]: { + windowMs: process.env.PROSOPO_PROVIDER_UPDATE_WINDOW, + limit: process.env.PROSOPO_PROVIDER_UPDATE_LIMIT, + }, + } +} diff --git a/packages/cli/src/prosopo.config.ts b/packages/cli/src/prosopo.config.ts index 5b7214f532..ca39e92836 100644 --- a/packages/cli/src/prosopo.config.ts +++ b/packages/cli/src/prosopo.config.ts @@ -26,6 +26,7 @@ import { } from '@prosopo/types' import { getAddress, getPassword, getSecret } from './process.env.js' import { getLogLevel } from '@prosopo/common' +import { getRateLimitConfig } from './RateLimiter.js' function getMongoURI(): string { const protocol = process.env.PROSOPO_DATABASE_PROTOCOL || 'mongodb' @@ -83,5 +84,6 @@ export default function getConfig( devOnlyWatchEvents: process.env._DEV_ONLY_WATCH_EVENTS === 'true', mongoEventsUri: process.env.PROSOPO_MONGO_EVENTS_URI || '', mongoCaptchaUri: process.env.PROSOPO_MONGO_CAPTCHA_URI || '', + rateLimits: getRateLimitConfig(), } as ProsopoConfigInput) } diff --git a/packages/cli/src/start.ts b/packages/cli/src/start.ts index 1f54989cb9..06b6aec34d 100644 --- a/packages/cli/src/start.ts +++ b/packages/cli/src/start.ts @@ -11,6 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +import { CombinedApiPaths } from '@prosopo/types' import { ProviderEnvironment } from '@prosopo/env' import { Server } from 'node:net' import { getDB, getSecret } from './process.env.js' @@ -21,6 +22,7 @@ import { prosopoAdminRouter, prosopoRouter, prosopoVerifyRouter, storeCaptchasEx import cors from 'cors' import express from 'express' import getConfig from './prosopo.config.js' +import rateLimit from 'express-rate-limit' function startApi(env: ProviderEnvironment, admin = false): Server { env.logger.info(`Starting Prosopo API`) @@ -37,6 +39,13 @@ function startApi(env: ProviderEnvironment, admin = false): Server { apiApp.use(prosopoAdminRouter(env)) } + // Rate limiting + const rateLimits = env.config.rateLimits + for (const [path, limit] of Object.entries(rateLimits)) { + const enumPath = path as CombinedApiPaths + apiApp.use(enumPath, rateLimit(limit)) + } + return apiApp.listen(apiPort, () => { env.logger.info(`Prosopo app listening at http://localhost:${apiPort}`) }) diff --git a/packages/procaptcha-frictionless/src/ProcaptchaFrictionless.tsx b/packages/procaptcha-frictionless/src/ProcaptchaFrictionless.tsx index 5abf7e8e2d..88b1d0f4f0 100644 --- a/packages/procaptcha-frictionless/src/ProcaptchaFrictionless.tsx +++ b/packages/procaptcha-frictionless/src/ProcaptchaFrictionless.tsx @@ -11,12 +11,12 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +import { BotDetectionFunction, ProcaptchaFrictionlessProps } from '@prosopo/types' import { Procaptcha } from '@prosopo/procaptcha-react' import { ProcaptchaPlaceholder } from '@prosopo/web-components' import { ProcaptchaPow } from '@prosopo/procaptcha-pow' -import { useEffect, useState } from 'react' -import { BotDetectionFunction, ProcaptchaFrictionlessProps } from '@prosopo/types' import { isBot } from '@prosopo/detector' +import { useEffect, useState } from 'react' const customDetectBot: BotDetectionFunction = async () => { return await isBot().then((result) => { diff --git a/packages/types/src/config/config.ts b/packages/types/src/config/config.ts index f22c952137..2acb69d8a1 100644 --- a/packages/types/src/config/config.ts +++ b/packages/types/src/config/config.ts @@ -12,6 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { ApiPathRateLimits, ProviderDefaultRateLimits } from '../provider/index.js' +import { + DEFAULT_IMAGE_CAPTCHA_SOLUTION_TIMEOUT, + DEFAULT_IMAGE_CAPTCHA_TIMEOUT, + DEFAULT_IMAGE_CAPTCHA_VERIFIED_TIMEOUT, + DEFAULT_IMAGE_MAX_VERIFIED_TIME_CACHED, + DEFAULT_MAX_VERIFIED_TIME_CONTRACT, + DEFAULT_POW_CAPTCHA_CACHED_TIMEOUT, + DEFAULT_POW_CAPTCHA_SOLUTION_TIMEOUT, + DEFAULT_POW_CAPTCHA_VERIFIED_TIMEOUT, +} from './timeouts.js' import { NetworkNamesSchema, ProsopoNetworkSchema } from './network.js' import { input } from 'zod' import { literal } from 'zod' @@ -32,25 +43,6 @@ export const EnvironmentTypesSchema = zEnum(['development', 'staging', 'producti export type EnvironmentTypes = zInfer -const ONE_MINUTE = 60 * 1000 -// The timeframe in which a user must complete an image captcha (1 minute) -export const DEFAULT_IMAGE_CAPTCHA_TIMEOUT = ONE_MINUTE -// The timeframe in which an image captcha solution remains valid on the page before timing out (2 minutes) -export const DEFAULT_IMAGE_CAPTCHA_SOLUTION_TIMEOUT = DEFAULT_IMAGE_CAPTCHA_TIMEOUT * 2 -// The timeframe in which an image captcha solution must be verified within (3 minutes) -export const DEFAULT_IMAGE_CAPTCHA_VERIFIED_TIMEOUT = DEFAULT_IMAGE_CAPTCHA_TIMEOUT * 3 -// The time in milliseconds that a cached, verified, image captcha solution is valid for (15 minutes) -export const DEFAULT_IMAGE_MAX_VERIFIED_TIME_CACHED = DEFAULT_IMAGE_CAPTCHA_TIMEOUT * 15 -// The timeframe in which a pow captcha solution remains valid on the page before timing out (1 minute) -export const DEFAULT_POW_CAPTCHA_SOLUTION_TIMEOUT = ONE_MINUTE -// The timeframe in which a pow captcha must be completed and verified (2 minutes) -export const DEFAULT_POW_CAPTCHA_VERIFIED_TIMEOUT = DEFAULT_POW_CAPTCHA_SOLUTION_TIMEOUT * 2 -// The time in milliseconds that a Provider cached, verified, pow captcha solution is valid for (3 minutes) -export const DEFAULT_POW_CAPTCHA_CACHED_TIMEOUT = DEFAULT_POW_CAPTCHA_SOLUTION_TIMEOUT * 3 -// The time in milliseconds since the last correct captcha recorded in the contract (15 minutes), after which point, the -// user will be required to complete another captcha -export const DEFAULT_MAX_VERIFIED_TIME_CONTRACT = ONE_MINUTE * 15 - export const DatabaseConfigSchema = record( EnvironmentTypesSchema, object({ @@ -252,6 +244,7 @@ export const ProsopoConfigSchema = ProsopoBasicConfigSchema.merge( server: ProsopoImageServerConfigSchema, mongoEventsUri: string().optional(), mongoCaptchaUri: string().optional(), + rateLimits: ApiPathRateLimits.default(ProviderDefaultRateLimits), }) ) diff --git a/packages/types/src/config/index.ts b/packages/types/src/config/index.ts index d27e995900..71a7573d22 100644 --- a/packages/types/src/config/index.ts +++ b/packages/types/src/config/index.ts @@ -13,3 +13,4 @@ // limitations under the License. export * from './config.js' export * from './network.js' +export * from './timeouts.js' diff --git a/packages/types/src/config/timeouts.ts b/packages/types/src/config/timeouts.ts new file mode 100644 index 0000000000..631d887fe9 --- /dev/null +++ b/packages/types/src/config/timeouts.ts @@ -0,0 +1,31 @@ +// Copyright 2021-2024 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +const ONE_MINUTE = 60 * 1000 +// The timeframe in which a user must complete an image captcha (1 minute) +export const DEFAULT_IMAGE_CAPTCHA_TIMEOUT = ONE_MINUTE +// The timeframe in which an image captcha solution remains valid on the page before timing out (2 minutes) +export const DEFAULT_IMAGE_CAPTCHA_SOLUTION_TIMEOUT = DEFAULT_IMAGE_CAPTCHA_TIMEOUT * 2 +// The timeframe in which an image captcha solution must be verified within (3 minutes) +export const DEFAULT_IMAGE_CAPTCHA_VERIFIED_TIMEOUT = DEFAULT_IMAGE_CAPTCHA_TIMEOUT * 3 +// The time in milliseconds that a cached, verified, image captcha solution is valid for (15 minutes) +export const DEFAULT_IMAGE_MAX_VERIFIED_TIME_CACHED = DEFAULT_IMAGE_CAPTCHA_TIMEOUT * 15 +// The timeframe in which a pow captcha solution remains valid on the page before timing out (1 minute) +export const DEFAULT_POW_CAPTCHA_SOLUTION_TIMEOUT = ONE_MINUTE +// The timeframe in which a pow captcha must be completed and verified (2 minutes) +export const DEFAULT_POW_CAPTCHA_VERIFIED_TIMEOUT = DEFAULT_POW_CAPTCHA_SOLUTION_TIMEOUT * 2 +// The time in milliseconds that a Provider cached, verified, pow captcha solution is valid for (3 minutes) +export const DEFAULT_POW_CAPTCHA_CACHED_TIMEOUT = DEFAULT_POW_CAPTCHA_SOLUTION_TIMEOUT * 3 +// The time in milliseconds since the last correct captcha recorded in the contract (15 minutes), after which point, the +// user will be required to complete another captcha +export const DEFAULT_MAX_VERIFIED_TIME_CONTRACT = ONE_MINUTE * 15 diff --git a/packages/types/src/provider/api.ts b/packages/types/src/provider/api.ts index 2e67bbd4d1..6aae427ded 100644 --- a/packages/types/src/provider/api.ts +++ b/packages/types/src/provider/api.ts @@ -13,10 +13,22 @@ // limitations under the License. import { ApiParams } from '../api/params.js' import { CaptchaSolutionSchema, CaptchaWithProof } from '../datasets/index.js' -import { DEFAULT_IMAGE_MAX_VERIFIED_TIME_CACHED, DEFAULT_POW_CAPTCHA_VERIFIED_TIMEOUT } from '../config/index.js' +import { DEFAULT_IMAGE_MAX_VERIFIED_TIME_CACHED, DEFAULT_POW_CAPTCHA_VERIFIED_TIMEOUT } from '../config/timeouts.js' import { Hash, Provider } from '@prosopo/captcha-contract/types-returns' import { ProcaptchaTokenSpec } from '../procaptcha/index.js' -import { array, input, number, object, output, string, infer as zInfer } from 'zod' +import { + ZodDefault, + ZodNumber, + ZodObject, + ZodOptional, + array, + input, + number, + object, + output, + string, + infer as zInfer, +} from 'zod' export enum ApiPaths { GetImageCaptchaChallenge = '/v1/prosopo/provider/captcha/image', @@ -38,6 +50,54 @@ export enum AdminApiPaths { ProviderUpdate = '/v1/prosopo/provider/admin/update', } +export type CombinedApiPaths = ApiPaths | AdminApiPaths + +export const ProviderDefaultRateLimits = { + [ApiPaths.GetImageCaptchaChallenge]: { windowMs: 60000, limit: 30 }, + [ApiPaths.GetPowCaptchaChallenge]: { windowMs: 60000, limit: 60 }, + [ApiPaths.SubmitImageCaptchaSolution]: { windowMs: 60000, limit: 60 }, + [ApiPaths.SubmitPowCaptchaSolution]: { windowMs: 60000, limit: 60 }, + [ApiPaths.VerifyPowCaptchaSolution]: { windowMs: 60000, limit: 60 }, + [ApiPaths.VerifyImageCaptchaSolutionDapp]: { windowMs: 60000, limit: 60 }, + [ApiPaths.VerifyImageCaptchaSolutionUser]: { windowMs: 60000, limit: 60 }, + [ApiPaths.GetProviderStatus]: { windowMs: 60000, limit: 60 }, + [ApiPaths.GetProviderDetails]: { windowMs: 60000, limit: 60 }, + [ApiPaths.SubmitUserEvents]: { windowMs: 60000, limit: 60 }, + [AdminApiPaths.BatchCommit]: { windowMs: 60000, limit: 5 }, + [AdminApiPaths.UpdateDataset]: { windowMs: 60000, limit: 5 }, + [AdminApiPaths.ProviderDeregister]: { windowMs: 60000, limit: 1 }, + [AdminApiPaths.ProviderUpdate]: { windowMs: 60000, limit: 5 }, +} + +type RateLimit = { + windowMs: number + limit: number +} + +type RateLimitSchemaType = ZodObject<{ + windowMs: ZodDefault> + limit: ZodDefault> +}> + +// Utility function to create Zod schemas with defaults +const createRateLimitSchemaWithDefaults = (paths: Record) => + object( + Object.entries(paths).reduce( + (schemas, [path, defaults]) => { + const enumPath = path as CombinedApiPaths + schemas[enumPath] = object({ + windowMs: number().optional().default(defaults.windowMs), + limit: number().optional().default(defaults.limit), + }) + + return schemas + }, + {} as Record + ) + ) + +export const ApiPathRateLimits = createRateLimitSchemaWithDefaults(ProviderDefaultRateLimits) + export interface DappUserSolutionResult { [ApiParams.captchas]: CaptchaIdAndProof[] partialFee?: string