diff --git a/dev/scripts/src/migrates/provider.db.2.0.1.to.2.0.2.ts b/dev/scripts/src/migrates/provider.db.2.0.1.to.2.0.2.ts deleted file mode 100644 index 515fc12e4b..0000000000 --- a/dev/scripts/src/migrates/provider.db.2.0.1.to.2.0.2.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { loadEnv } from "@prosopo/dotenv"; -import { CaptchaStatus } from "@prosopo/types"; -import { - type PoWCaptchaStored, - type UserCommitment, - UserCommitmentRecord, -} from "@prosopo/types-database"; -import { at } from "@prosopo/util"; -// 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 { MongoClient } from "mongodb"; - -loadEnv(); - -const MONGO_URL = - process.env.MONGO_URL || - "mongodb://root:root@localhost:27017/migrate?authSource=admin"; - -const MIGRATE_FROM_DB = process.env.MIGRATE_FROM_DB || "migrate"; -const MIGRATE_TO_DB = process.env.MIGRATE_TO_DB || "migrated"; - -console.log("MONGO_URL: ", MONGO_URL); - -// connect to the mongo db -const client = await MongoClient.connect(MONGO_URL); - -/* User Commitments Migration - -Get all user commitments from the `usercommitments` collection - -User commitments will look like this: - -{ - "_id" : ObjectId("6602b25af1369672ae2ed491"), - "id" : "0x860c75741153846518ab845f05c1a38b685771e4ab1a2650757a377a08b1e111", - "__v" : 0, - "batched" : false, - "completedAt" : 4638574, - "dappContract" : "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM", - "datasetId" : "0xe666b35451f302b9fccfbe783b1de9a6a4420b840abed071931d68a9ccc1c21d", - "processed" : false, - "providerAccount" : "5FnBurrfqWgSLJFMsojjEP74mLX1vZZ9ASyNXKfA5YXu8FR2", - "requestedAt" : 4638572, - "status" : "Approved", - "userAccount" : "5F8NZ7huGjaQ7p3pPAJ5cMh736BJrmhp7FDjghbGMSNX5kRe", - "userSignature" : [ - 138,231,58,36,156,179,40,26,60,84,12,233,229,226,6,39,248,41,104,47,86,204,157,49,97,12,181,34,72,196,141,111,242,184,255,104,147,116,85,149,53,144,82,163,37,90,71,85,0,190,23,248,242,197,97,169,165,208,106,90,190,132,90,0 - ] -} - -They need to made to look like this: - -{ - _id: ObjectId('66cedfe728495be1a929aac3'), - id: '0xc35b0686f7097764b6cc27dff7a091a1f5e1fbd97dcfe99a1fc64f79d36e968c', - __v: 0, - dappAccount: '5HGjWAeFDfFCWPsjFQdVV2Msvz2XtMktvgocEZcCj68kUMaw', - datasetId: '0x9f460e81ac9c71b486f796a21bb36e2263694756a6621134d110da217fd3ef25', - ipAddress: '::ffff:127.0.0.1', - lastUpdatedTimestamp: 1724833767163, - providerAccount: '5EjTA28bKSbFPPyMbUjNtArxyqjwq38r1BapVmLZShaqEedV', - requestedAtTimestamp: 1724833767140, - result: { status: 'Approved' }, - serverChecked: false, - userAccount: '5H9NydeNeQ1Jkr9YehjJDGB1tgc3VuoYGvG7na4zvNDg4k3r', - userSignature: '0xea128a06d374d94769b126c242a990c68c85c43f4033b594f102dee1b8d9880d2695f5fbf5994d9cb95a16daaada2bfd0c0d95b4684a51513a42f7c8d5ef9c8f', - userSubmitted: true, - storedAtTimestamp: 1724835600089 -} - -- id should remain as is -- dappContract should be renamed to dappAccount -- batched is not required and should be removed -- completedAt is not required and should be removed -- datasetId should remain as is -- processed is not required and should be removed -- providerAccount should remain as is -- requestedAt should be renamed to requestedAtTimestamp and converted to a timestamp with value 0 -- status should be moved into a result object -- userAccount should remain as is -- userSignature should be converted to a hex string -- userSubmitted should be added with a value of true -- lastUpdatedTimestamp should be added with a value of 0 -- serverChecked should be added with a value of false -- ipAddress should be added with a value of 'NO_IP_ADDRESS' - - */ - -const rococoDate = { 5359899: new Date("2024-05-23 16:15:22Z") }; - -const rococoBlock = Number.parseInt(at(Object.keys(rococoDate), 0)); - -const rococoTime = at(Object.values(rococoDate), 0).getTime(); - -const migrateUserCommitments = async () => { - const collection = client.db(MIGRATE_FROM_DB).collection("usercommitments"); - console.log(`${await collection.count()} documents in collection`); - - const userCommitments = await collection.find().toArray(); - - console.log("Found user commitments: ", userCommitments.length); - - const newCollection = client.db(MIGRATE_TO_DB).collection("usercommitments"); - - const results = []; - for (const commitment of userCommitments) { - const { - _id, - id, - dappContract, - datasetId, - providerAccount, - requestedAt, - status, - userAccount, - userSignature, - } = commitment; - - const secondsDiff = (rococoBlock - requestedAt) * 6; - - const requestedAtTimestamp = new Date(rococoTime - secondsDiff).getTime(); - - const userSignatureHex = Buffer.from(userSignature).toString("hex"); - - const record: UserCommitment = { - id, - dappAccount: dappContract, - datasetId, - ipAddress: "NO_IP_ADDRESS", - headers: {}, - lastUpdatedTimestamp: requestedAtTimestamp, - providerAccount, - requestedAtTimestamp, - result: { status }, - serverChecked: false, - userAccount, - userSignature: userSignatureHex, - userSubmitted: true, - }; - - console.log("updating record: ", id); - results.push( - await newCollection.updateOne( - { _id }, - { - $set: record, - }, - ), - ); - } - await newCollection.updateMany( - {}, - { - $unset: { - status: 1, - completedAt: 1, - requestedAt: 1, - processed: 1, - batched: 1, - dappContract: 1, - storedAtTimestamp: 1, - }, - }, - ); - - return results; -}; - -/* - Get all pow captchas from the `powcaptchas` collection - - Pow captchas will look like this: - - { - "_id" : ObjectId("660049a500b25bf4d6558a30"), - "challenge" : "4614665___5FzBkUs1KddN4xXHqGGbYMn8AXLgkc2XvkEKHDYCwecY99Bf___5HUBceb4Du6dvMA9BiwN5VzUrzUsX9Zp7z7nSR2cC1TCv5jg", - "checked" : false, - "__v" : 0 - } - - They need to look like this: - - { - _id: ObjectId('66cdc7d671e639ca19b194ec'), - challenge: '1724762070756___5CiPPseXPECbkjWCa6MnjNokrgYjMqmKndv2rSnekmSK2DjL___5HGjWAeFDfFCWPsjFQdVV2Msvz2XtMktvgocEZcCj68kUMaw', - dappAccount: '5HGjWAeFDfFCWPsjFQdVV2Msvz2XtMktvgocEZcCj68kUMaw', - userAccount: '5CiPPseXPECbkjWCa6MnjNokrgYjMqmKndv2rSnekmSK2DjL', - requestedAtTimestamp: 1724762070756, - lastUpdatedTimestamp: 1724762070757, - result: { status: 'Pending' }, - difficulty: 4, - ipAddress: '::ffff:127.0.0.1', - userSubmitted: false, - serverChecked: false, - __v: 0, - storedAtTimestamp: 1724762072716 - } - - - checked should be removed - - dappAccount can be extracted from the challenge by splitting on ___ and taking the last element - - userAccount can be extracted from the challenge by splitting on ___ and taking the second element - - requestedAtTimestamp should be extracted from the challenge by splitting on ___ and taking the first element, then converting to a timestamp - - lastUpdatedTimestamp should be set to the same as requestedAtTimestamp - - result should be added with a status of 'Approved' - - difficulty should be added with a value of 4 - - ipAddress should be added with a value of NO_IP_ADDRESS - - userSubmitted should be added with a value of true - - serverChecked should be added with a value of false - */ - -const migratePowCaptchas = async () => { - const collection = client.db(MIGRATE_FROM_DB).collection("powcaptchas"); - const newCollection = client.db(MIGRATE_TO_DB).collection("powcaptchas"); - - console.log(`${await collection.count()} documents in collection`); - - const powCaptchas = await collection.find().toArray(); - - console.log("Found pow captchas: ", powCaptchas.length); - - const results = []; - for (const captcha of powCaptchas) { - const { _id, challenge } = captcha; - - const [requestedAt, userAccount, dappAccount] = challenge.split("___"); - - const secondsDiff = (Number.parseInt(requestedAt) - requestedAt) * 6; - - const requestedAtTimestamp = new Date(rococoTime - secondsDiff).getTime(); - - const record: PoWCaptchaStored = { - challenge, - dappAccount, - userAccount, - requestedAtTimestamp, - lastUpdatedTimestamp: requestedAtTimestamp, - result: { status: CaptchaStatus.approved }, - providerSignature: "NO_SIGNATURE_MIGRATED", - difficulty: 4, - ipAddress: "NO_IP_ADDRESS", - headers: {}, - userSubmitted: true, - serverChecked: false, - }; - - console.log("updating record: ", _id); - results.push( - await newCollection.updateOne( - { _id }, - { - $set: record, - }, - ), - ); - } - await newCollection.updateMany( - {}, - { - $unset: { - checked: 1, - storedAtTimestamp: 1, - }, - }, - ); - - return results; -}; - -const run = async () => { - const results = []; - results.push(await migrateUserCommitments()); - - results.push(await migratePowCaptchas()); - - return results; -}; - -run() - .then((result) => { - console.log(result); - process.exit(0); - }) - .catch((err) => { - console.error(err); - process.exit(1); - }); - -// get all pow captcha records from the `powcaptchas` collection diff --git a/docker/docker-compose.provider.yml b/docker/docker-compose.provider.yml index f8f52eb35e..a95659cc86 100644 --- a/docker/docker-compose.provider.yml +++ b/docker/docker-compose.provider.yml @@ -1,5 +1,6 @@ services: provider-dev: + container_name: provider profiles: - development build: @@ -24,6 +25,7 @@ services: start_period: 30s timeout: 10s provider: + container_name: provider profiles: - production - staging @@ -52,6 +54,7 @@ services: start_period: 30s timeout: 10s database-dev: + container_name: database profiles: - development image: mongo:6.0.17 @@ -75,6 +78,7 @@ services: start_period: 30s timeout: 10s database: + container_name: database profiles: - production - staging @@ -103,6 +107,7 @@ services: start_period: 30s timeout: 10s caddy: + container_name: caddy profiles: - production - staging @@ -135,6 +140,7 @@ services: start_period: 30s timeout: 10s watchtower: + container_name: watchtower profiles: - production - staging @@ -154,6 +160,7 @@ services: max-size: '100m' max-file: '1' vector: + container_name: vector profiles: - production - staging diff --git a/package-lock.json b/package-lock.json index d27c722eda..82724d6e5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13126,6 +13126,15 @@ "node": ">=10.13.0" } }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -21828,6 +21837,7 @@ "@prosopo/util": "2.1.7", "cron": "3.1.7", "express": "4.21.0", + "ip-address": "10.0.1", "node-fetch": "3.3.2", "uuid": "10.0.0", "zod": "3.23.8" @@ -21983,6 +21993,7 @@ "@polkadot/util": "12.6.2", "@prosopo/common": "2.1.7", "@prosopo/locale": "2.1.7", + "ip-address": "10.0.1", "scale-ts": "1.6.0", "zod": "3.23.8" }, diff --git a/packages/cli/src/argv.ts b/packages/cli/src/argv.ts index c81286a754..811d94e222 100644 --- a/packages/cli/src/argv.ts +++ b/packages/cli/src/argv.ts @@ -17,6 +17,7 @@ import type { ProsopoConfigOutput } from "@prosopo/types"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { + commandAddBlockRules, commandProviderSetDataset, commandSiteKeyRegister, commandStoreCaptchasExternally, @@ -44,6 +45,7 @@ export function processArgs( default: false, type: "boolean", } as const) + .command(commandAddBlockRules(pair, config, { logger })) .command(commandProviderSetDataset(pair, config, { logger })) .command(commandStoreCaptchasExternally(pair, config, { logger })) .command(commandSiteKeyRegister(pair, config, { logger })) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 1f88fdbed3..af37b6449f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -32,6 +32,8 @@ async function main() { unsolved: { count: 0 }, }); + log.info(config); + if (config.devOnlyWatchEvents) { log.warn( ` diff --git a/packages/cli/src/commands/addBlockRules.ts b/packages/cli/src/commands/addBlockRules.ts new file mode 100644 index 0000000000..1dca88264b --- /dev/null +++ b/packages/cli/src/commands/addBlockRules.ts @@ -0,0 +1,84 @@ +// 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 type { KeyringPair } from "@polkadot/keyring/types"; +import { LogLevel, type Logger, getLogger } from "@prosopo/common"; +import { ProviderEnvironment } from "@prosopo/env"; +import { Tasks } from "@prosopo/provider"; +import type { ProsopoConfigOutput } from "@prosopo/types"; +import type { ArgumentsCamelCase, Argv } from "yargs"; +import * as z from "zod"; +import { loadJSONFile } from "../files.js"; + +export default ( + pair: KeyringPair, + config: ProsopoConfigOutput, + cmdArgs?: { logger?: Logger }, +) => { + const logger = + cmdArgs?.logger || + getLogger(LogLevel.enum.info, "cli.provider_set_data_set"); + + return { + command: "add_block_rules", + describe: "Add a rule for blocking requests to the database", + builder: (yargs: Argv) => + yargs + .option("ips", { + type: "array" as const, + demandOption: false, + desc: "The ips to be blocked", + } as const) + .option("users", { + type: "array" as const, + demandOption: false, + desc: "The users to be blocked", + } as const) + .option("dapp", { + type: "string" as const, + demandOption: false, + desc: "The users to be blocked", + } as const) + .option("global", { + type: "string" as const, + demandOption: true, + default: true, + desc: "Whether the ip is to be blocked globally or not", + } as const), + handler: async (argv: ArgumentsCamelCase) => { + try { + const env = new ProviderEnvironment(config, pair); + await env.isReady(); + const tasks = new Tasks(env); + if (argv.ips) { + await tasks.clientTaskManager.addIPBlockRules( + argv.ips as unknown as string[], + argv.global as boolean, + argv.dapp as unknown as string, + ); + } + if (argv.users) { + await tasks.clientTaskManager.addUserBlockRules( + argv.users as unknown as string[], + argv.dapp as unknown as string, + ); + } + logger.info("IP Block rules added"); + } catch (err) { + logger.error(err); + } + }, + middlewares: [], + }; +}; diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 03768441ba..747d8d1962 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.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. +export { default as commandAddBlockRules } from "./addBlockRules.js"; export { default as commandProviderSetDataset } from "./providerSetDataset.js"; export { default as commandStoreCaptchasExternally } from "./storeCaptchasExternally.js"; export { default as commandVersion } from "./version.js"; diff --git a/packages/cli/src/start.ts b/packages/cli/src/start.ts index 28b3dd475b..75421e250b 100644 --- a/packages/cli/src/start.ts +++ b/packages/cli/src/start.ts @@ -24,7 +24,7 @@ import { prosopoVerifyRouter, storeCaptchasExternally, } from "@prosopo/provider"; -import { authMiddleware } from "@prosopo/provider"; +import { authMiddleware, blockMiddleware } from "@prosopo/provider"; import type { CombinedApiPaths } from "@prosopo/types"; import cors from "cors"; import express from "express"; @@ -48,6 +48,8 @@ function startApi( apiApp.use(cors()); apiApp.use(express.json({ limit: "50mb" })); apiApp.use(i18nMiddleware({})); + // Blocking middleware will run on any routes defined after this point + apiApp.use(blockMiddleware(env)); apiApp.use(prosopoRouter(env)); apiApp.use(prosopoVerifyRouter(env)); @@ -107,6 +109,6 @@ export async function start( } export async function startDev(env?: ProviderEnvironment, admin?: boolean) { - start(env, admin, 9238); + //start(env, admin, 9238); return await start(env, admin); } diff --git a/packages/database/src/base/mongo.ts b/packages/database/src/base/mongo.ts index 8cbfb18f30..1b24195623 100644 --- a/packages/database/src/base/mongo.ts +++ b/packages/database/src/base/mongo.ts @@ -73,65 +73,68 @@ export class MongoDatabase { */ async connect(): Promise { this.logger.info(`Mongo url: ${this.safeURL}`); - - if (this.connected) { - this.logger.info(`Database connection to ${this.safeURL} already open`); - return; - } - - this.connection = await new Promise((resolve, reject) => { - const connection = mongoose.createConnection(this.url, { - dbName: this.dbname, - serverApi: ServerApiVersion.v1, - }); - - connection.on("open", () => { - this.logger.info(`Database connection to ${this.safeURL} opened`); - this.connected = true; - resolve(connection); - }); - - connection.on("error", (err) => { - this.connected = false; - this.logger.error(`Database error: ${err}`); - reject(err); - }); - - connection.on("connected", () => { - this.logger.info(`Database connected to ${this.safeURL}`); - this.connected = true; - resolve(connection); - }); - - connection.on("disconnected", () => { - this.connected = false; - this.logger.info(`Database disconnected from ${this.safeURL}`); - }); - - connection.on("reconnected", () => { - this.logger.info(`Database reconnected to ${this.safeURL}`); - this.connected = true; - resolve(connection); - }); - - connection.on("reconnectFailed", () => { - this.connected = false; - this.logger.error(`Database reconnect failed to ${this.safeURL}`); + try { + if (this.connected) { + this.logger.info(`Database connection to ${this.safeURL} already open`); + return; + } + this.connection = await new Promise((resolve, reject) => { + const connection = mongoose.createConnection(this.url, { + dbName: this.dbname, + serverApi: ServerApiVersion.v1, + }); + + connection.on("open", () => { + this.logger.info(`Database connection to ${this.safeURL} opened`); + this.connected = true; + resolve(connection); + }); + + connection.on("error", (err) => { + this.connected = false; + this.logger.error(`Database error: ${err}`); + reject(err); + }); + + connection.on("connected", () => { + this.logger.info(`Database connected to ${this.safeURL}`); + this.connected = true; + resolve(connection); + }); + + connection.on("disconnected", () => { + this.connected = false; + this.logger.info(`Database disconnected from ${this.safeURL}`); + }); + + connection.on("reconnected", () => { + this.logger.info(`Database reconnected to ${this.safeURL}`); + this.connected = true; + resolve(connection); + }); + + connection.on("reconnectFailed", () => { + this.connected = false; + this.logger.error(`Database reconnect failed to ${this.safeURL}`); + }); + + connection.on("close", () => { + this.connected = false; + this.logger.info(`Database connection to ${this.safeURL} closed`); + }); + + connection.on("fullsetup", () => { + this.connected = true; + this.logger.info( + `Database connection to ${this.safeURL} is fully setup`, + ); + resolve(connection); + }); }); - - connection.on("close", () => { - this.connected = false; - this.logger.info(`Database connection to ${this.safeURL} closed`); - }); - - connection.on("fullsetup", () => { - this.connected = true; - this.logger.info( - `Database connection to ${this.safeURL} is fully setup`, - ); - resolve(connection); - }); - }); + } catch (e) { + this.logger.error(`Database connection error: ${e}`); + throw e; + } } /** Close connection to the database */ diff --git a/packages/database/src/databases/provider.ts b/packages/database/src/databases/provider.ts index 9b660c6d93..7645f23d92 100644 --- a/packages/database/src/databases/provider.ts +++ b/packages/database/src/databases/provider.ts @@ -39,6 +39,8 @@ import { type ClientRecord, ClientRecordSchema, DatasetRecordSchema, + type IPBlockRuleRecord, + IPBlockRuleRecordSchema, type IProviderDatabase, type IUserDataSlim, PendingRecordSchema, @@ -57,6 +59,9 @@ import { type StoredStatus, StoredStatusNames, type Tables, + type UserAccountBlockRule, + type UserAccountBlockRuleRecord, + UserAccountBlockRuleSchema, type UserCommitment, type UserCommitmentRecord, UserCommitmentRecordSchema, @@ -64,6 +69,7 @@ import { type UserSolutionRecord, UserSolutionRecordSchema, } from "@prosopo/types-database"; +import type { IPBlockRuleMongo } from "@prosopo/types-database"; import type { DeleteResult } from "mongodb"; import type { ObjectId } from "mongoose"; import { MongoDatabase } from "../base/mongo.js"; @@ -79,6 +85,8 @@ enum TableNames { powcaptcha = "powcaptcha", client = "client", session = "session", + ipblockrules = "ipblockrules", + userblockrules = "userblockrules", } const PROVIDER_TABLES = [ @@ -132,6 +140,16 @@ const PROVIDER_TABLES = [ modelName: "Session", schema: SessionRecordSchema, }, + { + collectionName: TableNames.ipblockrules, + modelName: "IPBlockRules", + schema: IPBlockRuleRecordSchema, + }, + { + collectionName: TableNames.userblockrules, + modelName: "UserAccountBlockRules", + schema: UserAccountBlockRuleSchema, + }, ]; export class ProviderDatabase @@ -504,7 +522,7 @@ export class ProviderDatabase components: PoWChallengeComponents, difficulty: number, providerSignature: string, - ipAddress: string, + ipAddress: bigint, headers: RequestHeaders, serverChecked = false, userSubmitted = false, @@ -855,7 +873,7 @@ export class ProviderDatabase salt: string, deadlineTimestamp: number, requestedAtTimestamp: number, - ipAddress: string, + ipAddress: bigint, ): Promise { if (!isHex(requestHash)) { throw new ProsopoDBError("DATABASE.INVALID_HASH", { @@ -1311,4 +1329,62 @@ export class ProviderDatabase .lean(); return doc ? doc : undefined; } + + /** + * @description Check if a request has a blocking rule associated with it + */ + async getIPBlockRuleRecord( + ipAddress: bigint, + ): Promise { + const doc = await this.tables?.ipblockrules + .findOne({ ip: Number(ipAddress) }) + .lean(); + return doc ? doc : undefined; + } + + /** + * @description Check if a request has a blocking rule associated with it + */ + async storeIPBlockRuleRecords(rules: IPBlockRuleRecord[]) { + await this.tables?.ipblockrules.bulkWrite( + rules.map((rule) => ({ + updateOne: { + filter: { ip: rule.ip }, + update: { $set: rule }, + upsert: true, + }, + })), + ); + } + + /** + * @description Check if a request has a blocking rule associated with it + */ + async getUserBlockRuleRecord( + userAccount: string, + dappAccount: string, + ): Promise { + const doc = await this.tables?.userblockrules + .findOne({ dappAccount, userAccount }) + .lean(); + return doc ? doc : undefined; + } + + /** + * @description Check if a request has a blocking rule associated with it + */ + async storeUserBlockRuleRecords(rules: UserAccountBlockRule[]) { + await this.tables?.userblockrules.bulkWrite( + rules.map((rule) => ({ + updateOne: { + filter: { + dappAccount: rule.dappAccount, + userAccount: rule.userAccount, + }, + update: { $set: rule }, + upsert: true, + }, + })), + ); + } } diff --git a/packages/env/src/provider.ts b/packages/env/src/provider.ts index 4f5f22ba1a..7d4ce5bdc1 100644 --- a/packages/env/src/provider.ts +++ b/packages/env/src/provider.ts @@ -1,4 +1,3 @@ -import type { ProsopoConfigOutput } from "@prosopo/types"; // Copyright 2021-2024 Prosopo (UK) Ltd. // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -12,6 +11,8 @@ import type { ProsopoConfigOutput } from "@prosopo/types"; // 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 type { ProsopoConfigOutput } from "@prosopo/types"; import { Environment } from "./env.js"; export class ProviderEnvironment extends Environment { diff --git a/packages/locale/src/locales/de.json b/packages/locale/src/locales/de.json index 09db00c112..9d2625f83e 100644 --- a/packages/locale/src/locales/de.json +++ b/packages/locale/src/locales/de.json @@ -106,7 +106,8 @@ "UNKNOWN": "Unbekannter API-Fehler", "SITE_KEY_NOT_REGISTERED": "Site-Key nicht registriert", "INCORRECT_CAPTCHA_TYPE": "Falscher CAPTCHA-Typ", - "INVALID_SITE_KEY": "Ungültiger Site-Schlüssel" + "INVALID_SITE_KEY": "Ungültiger Site-Schlüssel", + "INVALID_IP": "Ungültige IP" }, "CLI": { "PARAMETER_ERROR": "Ungültiger Parameter" diff --git a/packages/locale/src/locales/en.json b/packages/locale/src/locales/en.json index 6bb06c2587..72f5093d13 100644 --- a/packages/locale/src/locales/en.json +++ b/packages/locale/src/locales/en.json @@ -106,7 +106,8 @@ "UNKNOWN": "Unknown API error", "SITE_KEY_NOT_REGISTERED": "Site key not registered", "INCORRECT_CAPTCHA_TYPE": "Incorrect CAPTCHA type", - "INVALID_SITE_KEY": "Invalid site key" + "INVALID_SITE_KEY": "Invalid site key", + "INVALID_IP": "Invalid IP" }, "CLI": { "PARAMETER_ERROR": "Invalid parameter" diff --git a/packages/locale/src/locales/es.json b/packages/locale/src/locales/es.json index b8e9d360d3..8b1c1d2cb4 100644 --- a/packages/locale/src/locales/es.json +++ b/packages/locale/src/locales/es.json @@ -106,7 +106,8 @@ "UNKNOWN": "Error desconocido en el API", "SITE_KEY_NOT_REGISTERED": "Clave del sitio no registrada", "INCORRECT_CAPTCHA_TYPE": "Tipo di CAPTCHA errato", - "INVALID_SITE_KEY": "Clave de sitio no válida" + "INVALID_SITE_KEY": "Clave de sitio no válida", + "INVALID_IP": "IP no válida" }, "CLI": { "PARAMETER_ERROR": "Parámetro inválido" diff --git a/packages/locale/src/locales/fr.json b/packages/locale/src/locales/fr.json index bc4efc1bf4..3bbdfc5afa 100644 --- a/packages/locale/src/locales/fr.json +++ b/packages/locale/src/locales/fr.json @@ -106,7 +106,8 @@ "UNKNOWN": "Erreur API inconnue", "SITE_KEY_NOT_REGISTERED": "Clé du site non enregistrée", "INCORRECT_CAPTCHA_TYPE": "Type de CAPTCHA incorrect", - "INVALID_SITE_KEY": "Clé de site non valide" + "INVALID_SITE_KEY": "Clé de site non valide", + "INVALID_IP": "IP invalide" }, "CLI": { "PARAMETER_ERROR": "Paramètre invalide" diff --git a/packages/locale/src/locales/it.json b/packages/locale/src/locales/it.json index 36ee5fffb4..c346f756be 100644 --- a/packages/locale/src/locales/it.json +++ b/packages/locale/src/locales/it.json @@ -106,7 +106,8 @@ "UNKNOWN": "Errore API sconosciuto", "SITE_KEY_NOT_REGISTERED": "Chiave del sito non registrata", "INCORRECT_CAPTCHA_TYPE": "Tipo di CAPTCHA errato", - "INVALID_SITE_KEY": "Chiave del sito non valida" + "INVALID_SITE_KEY": "Chiave del sito non valida", + "INVALID_IP": "IP non valido" }, "CLI": { "PARAMETER_ERROR": "Parametro non valido" diff --git a/packages/locale/src/locales/pt-BR.json b/packages/locale/src/locales/pt-BR.json index 6820abc7e9..af8bdb2d1b 100644 --- a/packages/locale/src/locales/pt-BR.json +++ b/packages/locale/src/locales/pt-BR.json @@ -106,7 +106,8 @@ "UNKNOWN": "Erro desconhecido na API", "SITE_KEY_NOT_REGISTERED": "Chave do site não registrada", "INCORRECT_CAPTCHA_TYPE": "Tipo de CAPTCHA incorreto", - "INVALID_SITE_KEY": "Chave de site inválida" + "INVALID_SITE_KEY": "Chave de site inválida", + "INVALID_IP": "IP inválido" }, "CLI": { "PARAMETER_ERROR": "Parâmetro inválido" diff --git a/packages/locale/src/locales/pt.json b/packages/locale/src/locales/pt.json index fd84e52791..289fa11196 100644 --- a/packages/locale/src/locales/pt.json +++ b/packages/locale/src/locales/pt.json @@ -106,7 +106,8 @@ "UNKNOWN": "Erro desconhecido na API", "SITE_KEY_NOT_REGISTERED": "Chave do site não registrada", "INCORRECT_CAPTCHA_TYPE": "Tipo de CAPTCHA incorreto", - "INVALID_SITE_KEY": "Chave de site inválida" + "INVALID_SITE_KEY": "Chave de site inválida", + "INVALID_IP": "IP inválido" }, "CLI": { "PARAMETER_ERROR": "Parâmetro inválido" diff --git a/packages/provider/package.json b/packages/provider/package.json index c755afadba..0a8b3e2f48 100644 --- a/packages/provider/package.json +++ b/packages/provider/package.json @@ -39,6 +39,7 @@ "@prosopo/util": "2.1.7", "cron": "3.1.7", "express": "4.21.0", + "ip-address": "10.0.1", "node-fetch": "3.3.2", "uuid": "10.0.0", "zod": "3.23.8" @@ -48,9 +49,9 @@ "@types/uuid": "10.0.0", "@vitest/coverage-v8": "2.1.1", "concurrently": "9.0.1", + "del-cli": "6.0.0", "dotenv": "16.4.5", "npm-run-all": "4.1.5", - "del-cli": "6.0.0", "tslib": "2.7.0", "tsx": "4.19.1", "typescript": "5.6.2", diff --git a/packages/provider/src/api/block.ts b/packages/provider/src/api/block.ts new file mode 100644 index 0000000000..0c3498f970 --- /dev/null +++ b/packages/provider/src/api/block.ts @@ -0,0 +1,87 @@ +// 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 type { KeyringPair } from "@polkadot/keyring/types"; +import { hexToU8a, isHex } from "@polkadot/util"; +import { validateAddress } from "@polkadot/util-crypto/address"; +import { ProsopoApiError, ProsopoEnvError } from "@prosopo/common"; +import { ApiPrefix } from "@prosopo/types"; +import type { ProviderEnvironment } from "@prosopo/types-env"; +import type { NextFunction, Request, Response } from "express"; +import { Address6 } from "ip-address"; +import { getIPAddress } from "../util.js"; + +export const blockMiddleware = (env: ProviderEnvironment) => { + return async (req: Request, res: Response, next: NextFunction) => { + try { + // Stops this middleware from running on non-api routes like /json /favicon.ico etc + if (req.url.indexOf(ApiPrefix) === -1) { + next(); + return; + } + + // if no IP block + if (!req.ip) { + console.log("No IP", req.ip); + return res.status(401).json({ error: "Unauthorized" }); + } + + await env.isReady(); + + const ipAddress = getIPAddress(req.ip || ""); + const userAccount = req.body.user; + const dappAccount = req.body.dapp; + const rule = await env.getDb().getIPBlockRuleRecord(ipAddress.bigInt()); + if (rule && BigInt(rule.ip) === ipAddress.bigInt()) { + // block by IP address globally + if (rule.global) { + return res.status(401).json({ error: "Unauthorized" }); + } + + if (dappAccount) { + const dappRule = await env + .getDb() + .getIPBlockRuleRecord(ipAddress.bigInt(), dappAccount); + if ( + dappRule && + dappRule.dappAccount === dappAccount && + BigInt(dappRule.ip) === ipAddress.bigInt() + ) { + return res.status(401).json({ error: "Unauthorized" }); + } + } + } + + if (userAccount && dappAccount) { + const rule = await env + .getDb() + .getUserBlockRuleRecord(userAccount, dappAccount); + + if ( + rule && + rule.userAccount === userAccount && + rule.dappAccount === dappAccount + ) { + return res.status(401).json({ error: "Unauthorized" }); + } + } + + next(); + return; + } catch (err) { + console.error("Block Middleware Error:", err); + res.status(401).json({ error: "Unauthorized", message: err }); + return; + } + }; +}; diff --git a/packages/provider/src/api/captcha.ts b/packages/provider/src/api/captcha.ts index 7bb3daa249..2dd65f323d 100644 --- a/packages/provider/src/api/captcha.ts +++ b/packages/provider/src/api/captcha.ts @@ -35,13 +35,14 @@ import { type SubmitPowCaptchaSolutionBodyTypeOutput, type TGetImageCaptchaChallengePathAndParams, } from "@prosopo/types"; -import type { SessionRecord } from "@prosopo/types-database"; +import type { Session, SessionRecord } from "@prosopo/types-database"; import type { ProviderEnvironment } from "@prosopo/types-env"; import { flatten, version } from "@prosopo/util"; import express, { type Router } from "express"; import { v4 as uuidv4 } from "uuid"; import { getBotScore } from "../tasks/detection/getBotScore.js"; import { Tasks } from "../tasks/tasks.js"; +import { getIPAddress } from "../util.js"; import { handleErrors } from "./errorHandler.js"; const NO_IP_ADDRESS = "NO_IP_ADDRESS" as const; @@ -88,16 +89,6 @@ export function prosopoRouter(env: ProviderEnvironment): Router { ); } - try { - validateAddress(dapp, false, 42); - } catch (err) { - return next( - new ProsopoApiError("API.INVALID_SITE_KEY", { - context: { code: 400, error: err, siteKey: dapp }, - }), - ); - } - try { validateAddress(user, false, 42); @@ -115,7 +106,7 @@ export function prosopoRouter(env: ProviderEnvironment): Router { await tasks.imgCaptchaManager.getRandomCaptchasAndRequestHash( datasetId, user, - req.ip || NO_IP_ADDRESS, + getIPAddress(req.ip || ""), flatten(req.headers, ","), ); const captchaResponse: CaptchaResponseBody = { @@ -204,7 +195,7 @@ export function prosopoRouter(env: ProviderEnvironment): Router { parsed[ApiParams.signature].user.timestamp, Number.parseInt(parsed[ApiParams.timestamp]), parsed[ApiParams.signature].provider.requestHash, - req.ip || NO_IP_ADDRESS, + getIPAddress(req.ip || "").bigInt(), flatten(req.headers, ","), ); @@ -313,7 +304,7 @@ export function prosopoRouter(env: ProviderEnvironment): Router { }, challenge.difficulty, challenge.providerSignature, - req.ip || NO_IP_ADDRESS, + getIPAddress(req.ip || "").bigInt(), flatten(req.headers, ","), ); @@ -407,7 +398,7 @@ export function prosopoRouter(env: ProviderEnvironment): Router { nonce, verifiedTimeout, signature.user.timestamp, - req.ip || NO_IP_ADDRESS, + getIPAddress(req.ip || ""), flatten(req.headers, ","), ); const response: PowCaptchaSolutionResponse = { status: "ok", verified }; @@ -447,7 +438,7 @@ export function prosopoRouter(env: ProviderEnvironment): Router { }; return res.json(response); } - const sessionRecord: SessionRecord = { + const sessionRecord: Session = { sessionId: uuidv4(), createdAt: new Date(), }; diff --git a/packages/provider/src/index.ts b/packages/provider/src/index.ts index 70c2afa041..5c1f8c22a0 100644 --- a/packages/provider/src/index.ts +++ b/packages/provider/src/index.ts @@ -13,6 +13,7 @@ // limitations under the License. export * from "./tasks/index.js"; export * from "./util.js"; +export * from "./api/block.js"; export * from "./api/captcha.js"; export * from "./api/verify.js"; export * from "./api/admin.js"; diff --git a/packages/provider/src/tasks/client/clientTasks.ts b/packages/provider/src/tasks/client/clientTasks.ts index d248ee8e05..650f8a5b9f 100644 --- a/packages/provider/src/tasks/client/clientTasks.ts +++ b/packages/provider/src/tasks/client/clientTasks.ts @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { validateAddress } from "@polkadot/util-crypto/address"; import type { Logger } from "@prosopo/common"; import { CaptchaDatabase, ClientDatabase } from "@prosopo/database"; import { @@ -20,7 +21,16 @@ import { ScheduledTaskNames, ScheduledTaskStatus, } from "@prosopo/types"; -import type { ClientRecord, IProviderDatabase } from "@prosopo/types-database"; +import { + BlockRuleType, + type ClientRecord, + type IPAddressBlockRule, + type IProviderDatabase, + type PoWCaptchaStored, + type UserAccountBlockRule, + type UserCommitment, +} from "@prosopo/types-database"; +import { getIPAddress } from "../../util.js"; export class ClientTaskManager { config: ProsopoConfigOutput; @@ -63,7 +73,7 @@ export class ClientTaskManager { await this.providerDB.getUnstoredDappUserPoWCommitments(); // filter to only get records that have been updated since the last task - if (lastTask) { + if (lastTask?.updated) { this.logger.info( `Filtering records to only get updated records: ${JSON.stringify(lastTask)}`, ); @@ -74,24 +84,30 @@ export class ClientTaskManager { taskID, ); - commitments = commitments.filter( - (commitment) => - lastTask.updated && - commitment.lastUpdatedTimestamp && - (commitment.lastUpdatedTimestamp > lastTask.updated || - !commitment.lastUpdatedTimestamp), - ); - - powRecords = powRecords.filter((commitment) => { + const isCommitmentUpdated = ( + commitment: UserCommitment | PoWCaptchaStored, + ): boolean => { + const { lastUpdatedTimestamp, storedAtTimestamp } = commitment; return ( - lastTask.updated && - commitment.lastUpdatedTimestamp && - // either the update stamp is more recent than the last time this task ran or there is no update stamp, - // so it is a new record - (commitment.lastUpdatedTimestamp > lastTask.updated || - !commitment.lastUpdatedTimestamp) + !lastUpdatedTimestamp || + !storedAtTimestamp || + lastUpdatedTimestamp > storedAtTimestamp ); - }); + }; + + const commitmentUpdated = ( + commitment: UserCommitment | PoWCaptchaStored, + ): boolean => { + return !!lastTask.updated && isCommitmentUpdated(commitment); + }; + + commitments = commitments.filter((commitment) => + commitmentUpdated(commitment), + ); + + powRecords = powRecords.filter((commitment) => + commitmentUpdated(commitment), + ); } if (commitments.length || powRecords.length) { @@ -206,4 +222,38 @@ export class ClientTaskManager { } as ClientRecord, ]); } + + async addIPBlockRules( + ips: string[], + global: boolean, + dappAccount?: string, + ): Promise { + const rules: IPAddressBlockRule[] = ips.map((ip) => { + return { + ip: Number(getIPAddress(ip).bigInt()), + global, + type: BlockRuleType.ipAddress, + dappAccount, + }; + }); + await this.providerDB.storeIPBlockRuleRecords(rules); + } + + async addUserBlockRules( + userAccounts: string[], + dappAccount: string, + ): Promise { + validateAddress(dappAccount, false, 42); + const rules: UserAccountBlockRule[] = userAccounts.map((userAccount) => { + validateAddress(userAccount, false, 42); + return { + dappAccount, + userAccount, + type: BlockRuleType.userAccount, + // TODO don't store global on these + global: false, + }; + }); + await this.providerDB.storeUserBlockRuleRecords(rules); + } } diff --git a/packages/provider/src/tasks/imgCaptcha/imgCaptchaTasks.ts b/packages/provider/src/tasks/imgCaptcha/imgCaptchaTasks.ts index a689f23aae..c5f7c99d0a 100644 --- a/packages/provider/src/tasks/imgCaptcha/imgCaptchaTasks.ts +++ b/packages/provider/src/tasks/imgCaptcha/imgCaptchaTasks.ts @@ -38,6 +38,7 @@ import type { UserCommitment, } from "@prosopo/types-database"; import { at } from "@prosopo/util"; +import type { Address4, Address6 } from "ip-address"; import { shuffleArray } from "../../util.js"; import { buildTreeAndGetCommitmentId } from "./imgCaptchaTasksUtils.js"; @@ -82,7 +83,7 @@ export class ImgCaptchaManager { async getRandomCaptchasAndRequestHash( datasetId: string, userAccount: string, - ipAddress: string, + ipAddress: Address4 | Address6, headers: RequestHeaders, ): Promise<{ captchas: Captcha[]; @@ -147,7 +148,7 @@ export class ImgCaptchaManager { salt, deadlineTs, currentTime, - ipAddress, + ipAddress.bigInt(), headers, ); return { @@ -179,7 +180,7 @@ export class ImgCaptchaManager { userTimestampSignature: string, // the signature to indicate ownership of account timestamp: number, providerRequestHashSignature: string, - ipAddress: string, + ipAddress: bigint, headers: RequestHeaders, ): Promise { // check that the signature is valid (i.e. the user has signed the request hash with their private key, proving they own their account) diff --git a/packages/provider/src/tasks/powCaptcha/powTasks.ts b/packages/provider/src/tasks/powCaptcha/powTasks.ts index 07358adfb5..6b5dad9492 100644 --- a/packages/provider/src/tasks/powCaptcha/powTasks.ts +++ b/packages/provider/src/tasks/powCaptcha/powTasks.ts @@ -25,6 +25,7 @@ import { } from "@prosopo/types"; import type { IProviderDatabase } from "@prosopo/types-database"; import { at, verifyRecency } from "@prosopo/util"; +import type { Address4, Address6 } from "ip-address"; import { checkPowSignature, validateSolution } from "./powTasksUtils.js"; const logger = getLoggerDefault(); @@ -88,7 +89,7 @@ export class PowCaptchaManager { nonce: number, timeout: number, userTimestampSignature: string, - ipAddress: string, + ipAddress: Address4 | Address6, headers: RequestHeaders, ): Promise { // Check signatures before doing DB reads to avoid unnecessary network connections diff --git a/packages/provider/src/tests/unit/tasks/client/clientTasks.unit.test.ts b/packages/provider/src/tests/unit/tasks/client/clientTasks.unit.test.ts index c29237341e..d53d0015b5 100644 --- a/packages/provider/src/tests/unit/tasks/client/clientTasks.unit.test.ts +++ b/packages/provider/src/tests/unit/tasks/client/clientTasks.unit.test.ts @@ -225,23 +225,25 @@ describe("ClientTaskManager", () => { it("should not store commitments externally if they have been stored", async () => { const mockCommitments: Pick< UserCommitment, - "id" | "lastUpdatedTimestamp" + "id" | "lastUpdatedTimestamp" | "storedAtTimestamp" >[] = [ { id: "commitment1", // Image commitments were stored at time 1 lastUpdatedTimestamp: 1, + storedAtTimestamp: 1, }, ]; const mockPoWCommitments: Pick< PoWCaptchaStored, - "challenge" | "lastUpdatedTimestamp" + "challenge" | "lastUpdatedTimestamp" | "storedAtTimestamp" >[] = [ { challenge: "1234567___userAccount___dappAccount", // PoW commitments were stored at time 3 lastUpdatedTimestamp: 3, + storedAtTimestamp: 1, }, ]; diff --git a/packages/provider/src/tests/unit/tasks/imgCaptcha/imgCaptchaTasks.unit.test.ts b/packages/provider/src/tests/unit/tasks/imgCaptcha/imgCaptchaTasks.unit.test.ts index fff647f23f..81ffa5ce9e 100644 --- a/packages/provider/src/tests/unit/tasks/imgCaptcha/imgCaptchaTasks.unit.test.ts +++ b/packages/provider/src/tests/unit/tasks/imgCaptcha/imgCaptchaTasks.unit.test.ts @@ -32,7 +32,7 @@ import type { } from "@prosopo/types-database"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { ImgCaptchaManager } from "../../../../tasks/imgCaptcha/imgCaptchaTasks.js"; -import { shuffleArray } from "../../../../util.js"; +import { getIPAddress, shuffleArray } from "../../../../util.js"; // Mock dependencies vi.mock("@prosopo/datasets", () => ({ @@ -48,9 +48,12 @@ vi.mock("@polkadot/util", () => ({ u8aToHex: vi.fn(), stringToHex: vi.fn(), })); -vi.mock("../../../../util.js", () => ({ - shuffleArray: vi.fn(), -})); +vi.mock("../../../../util.js", async (importOriginal) => { + return { + ...(await importOriginal()), + shuffleArray: vi.fn(), + }; +}); vi.mock("../../../../tasks/imgCaptcha/imgCaptchaTasksUtils.js", () => ({ buildTreeAndGetCommitmentId: vi.fn(), })); @@ -151,7 +154,7 @@ describe("ImgCaptchaManager", () => { const datasetId = "datasetId"; const userAccount = "userAccount"; const dataset = { datasetId, captchas: [] }; - const ipAddress = "0.0.0.0"; + const ipAddress = getIPAddress("1.1.1.1"); const headers: RequestHeaders = { a: "1", b: "2", c: "3" }; // biome-ignore lint/suspicious/noExplicitAny: TODO fix (db.getDatasetDetails as any).mockResolvedValue(dataset); // biome-ignore lint/suspicious/noExplicitAny: TODO fix @@ -180,7 +183,7 @@ describe("ImgCaptchaManager", () => { it("should throw an error if dataset details are not found", async () => { const datasetId = "datasetId"; const userAccount = "userAccount"; - const ipAddress = "0.0.0.0"; + const ipAddress = getIPAddress("1.1.1.1"); const headers: RequestHeaders = { a: "1", b: "2", c: "3" }; // biome-ignore lint/suspicious/noExplicitAny: TODO fix @@ -322,7 +325,7 @@ describe("ImgCaptchaManager", () => { userSubmitted: true, serverChecked: false, requestedAtTimestamp: 0, - ipAddress: "0.0.0.0", + ipAddress: getIPAddress("1.1.1.1").bigInt(), headers: { a: "1", b: "2", c: "3" }, lastUpdatedTimestamp: Date.now(), }; @@ -367,7 +370,7 @@ describe("ImgCaptchaManager", () => { userSubmitted: true, serverChecked: false, requestedAtTimestamp: 0, - ipAddress: "0.0.0.0", + ipAddress: getIPAddress("1.1.1.1").bigInt(), headers: { a: "1", b: "2", c: "3" }, lastUpdatedTimestamp: Date.now(), }, diff --git a/packages/provider/src/tests/unit/tasks/powCaptcha/powTasks.unit.test.ts b/packages/provider/src/tests/unit/tasks/powCaptcha/powTasks.unit.test.ts index caa9a45156..1c39dc7f9e 100644 --- a/packages/provider/src/tests/unit/tasks/powCaptcha/powTasks.unit.test.ts +++ b/packages/provider/src/tests/unit/tasks/powCaptcha/powTasks.unit.test.ts @@ -33,6 +33,7 @@ import { checkPowSignature, validateSolution, } from "../../../../tasks/powCaptcha/powTasksUtils.js"; +import { getIPAddress } from "../../../../util.js"; vi.mock("@polkadot/util-crypto", () => ({ signatureVerify: vi.fn(), @@ -125,7 +126,7 @@ describe("PowCaptchaManager", () => { const userSignature = "testTimestampSignature"; const nonce = 12345; const timeout = 1000; - const ipAddress = "ipAddress"; + const ipAddress = getIPAddress("1.1.1.1"); const headers: RequestHeaders = { a: "1", b: "2", c: "3" }; const challengeRecord: PoWCaptchaStored = { challenge, @@ -136,7 +137,7 @@ describe("PowCaptchaManager", () => { result: { status: CaptchaStatus.pending }, userSubmitted: false, serverChecked: false, - ipAddress, + ipAddress: ipAddress.bigInt(), headers, providerSignature, lastUpdatedTimestamp: Date.now(), @@ -231,7 +232,7 @@ describe("PowCaptchaManager", () => { const nonce = 12345; const timeout = 1000; const timestampSignature = "testTimestampSignature"; - const ipAddress = "ipAddress"; + const ipAddress = getIPAddress("1.1.1.1"); const headers: RequestHeaders = { a: "1", b: "2", c: "3" }; const challengeRecord: PoWCaptchaStored = { challenge, @@ -241,7 +242,7 @@ describe("PowCaptchaManager", () => { result: { status: CaptchaStatus.pending }, userSubmitted: false, serverChecked: false, - ipAddress, + ipAddress: ipAddress.bigInt(), headers, providerSignature: "testSignature", difficulty, diff --git a/packages/provider/src/util.ts b/packages/provider/src/util.ts index a84de34834..bc6c01638c 100644 --- a/packages/provider/src/util.ts +++ b/packages/provider/src/util.ts @@ -14,10 +14,11 @@ import { decodeAddress, encodeAddress } from "@polkadot/util-crypto/address"; import { hexToU8a } from "@polkadot/util/hex"; import { isHex } from "@polkadot/util/is"; -import { ProsopoContractError } from "@prosopo/common"; +import { ProsopoContractError, ProsopoEnvError } from "@prosopo/common"; import { type ScheduledTaskNames, ScheduledTaskStatus } from "@prosopo/types"; import type { IDatabase, IProviderDatabase } from "@prosopo/types-database"; import { at } from "@prosopo/util"; +import { Address4, Address6 } from "ip-address"; export function encodeStringAddress(address: string) { try { @@ -64,3 +65,14 @@ export async function checkIfTaskIsRunning( } return false; } + +export const getIPAddress = (ipAddressString: string): Address4 | Address6 => { + try { + if (ipAddressString.match(/^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/)) { + return new Address4(ipAddressString); + } + return new Address6(ipAddressString); + } catch (e) { + throw new ProsopoEnvError("API.INVALID_IP"); + } +}; diff --git a/packages/types-database/src/types/provider.ts b/packages/types-database/src/types/provider.ts index 12e536cee3..e18ffca7c5 100644 --- a/packages/types-database/src/types/provider.ts +++ b/packages/types-database/src/types/provider.ts @@ -45,6 +45,7 @@ import { type ZodType, any, array, + bigint, boolean, nativeEnum, object, @@ -87,7 +88,7 @@ export interface StoredCaptcha { }; requestedAtTimestamp: Timestamp; deadlineTimestamp?: Timestamp; - ipAddress: string; + ipAddress: bigint; headers: RequestHeaders; userSubmitted: boolean; serverChecked: boolean; @@ -115,7 +116,7 @@ export const UserCommitmentSchema = object({ id: string(), result: CaptchaResultSchema, userSignature: string(), - ipAddress: string(), + ipAddress: bigint(), headers: object({}).catchall(string()), userSubmitted: boolean(), serverChecked: boolean(), @@ -181,7 +182,7 @@ export const PoWCaptchaRecordSchema = new Schema( error: { type: String, required: false }, }, difficulty: { type: Number, required: true }, - ipAddress: { type: String, required: true }, + ipAddress: { type: BigInt, required: true }, headers: { type: Object, required: true }, userSignature: { type: String, required: false }, userSubmitted: { type: Boolean, required: true }, @@ -209,7 +210,7 @@ export const UserCommitmentRecordSchema = new Schema({ }, error: { type: String, required: false }, }, - ipAddress: { type: String, required: true }, + ipAddress: { type: BigInt, required: true }, headers: { type: Object, required: true }, userSignature: { type: String, required: true }, userSubmitted: { type: Boolean, required: true }, @@ -280,7 +281,7 @@ export const PendingRecordSchema = new Schema( requestHash: { type: String, required: true }, deadlineTimestamp: { type: Number, required: true }, // unix timestamp requestedAtTimestamp: { type: Number, required: true }, // unix timestamp - ipAddress: { type: String, required: true }, + ipAddress: { type: BigInt, required: true }, headers: { type: Object, required: true }, }, { expireAfterSeconds: ONE_WEEK }, @@ -323,16 +324,68 @@ export const ScheduledTaskRecordSchema = new Schema( { expireAfterSeconds: ONE_WEEK }, ); -export interface SessionRecord { +export type Session = { sessionId: string; createdAt: Date; -} +}; + +export type SessionRecord = mongoose.Document & Session; export const SessionRecordSchema = new Schema({ sessionId: { type: String, required: true, unique: true }, createdAt: { type: Date, required: true }, }); +type BlockRule = { + global: boolean; + type: BlockRuleType; +}; + +export enum BlockRuleType { + ipAddress = "ipAddress", + userAccount = "userAccount", +} + +export interface IPAddressBlockRule extends BlockRule { + ip: number; + dappAccount?: string; +} + +export interface UserAccountBlockRule extends BlockRule { + dappAccount: string; + userAccount: string; +} + +// A rule to block users based on headers such as IP. Global rules apply to all clients. +export type IPBlockRuleRecord = mongoose.Document & IPAddressBlockRule; +export type UserAccountBlockRuleRecord = mongoose.Document & + UserAccountBlockRule; + +export type IPBlockRuleMongo = Omit & { + ip: number; +}; + +export const IPBlockRuleRecordSchema = new Schema({ + ip: { type: Number, required: true, unique: true }, + global: { type: Boolean, required: true }, + type: { type: String, enum: BlockRuleType, required: true }, +}); + +IPBlockRuleRecordSchema.index({ ip: 1 }, { unique: true }); + +export const UserAccountBlockRuleSchema = + new Schema({ + dappAccount: { type: String, required: true }, + userAccount: { type: String, required: true }, + global: { type: Boolean, required: true }, + type: { type: String, enum: BlockRuleType, required: true }, + }); + +UserAccountBlockRuleSchema.index( + { dappAccount: 1, userAccount: 1 }, + { unique: true }, +); + export interface IProviderDatabase extends IDatabase { // biome-ignore lint/suspicious/noExplicitAny: tables: Tables; @@ -370,7 +423,7 @@ export interface IProviderDatabase extends IDatabase { salt: string, deadlineTimestamp: number, requestedAtTimestamp: number, - ipAddress: string, + ipAddress: bigint, headers: RequestHeaders, ): Promise; @@ -472,7 +525,7 @@ export interface IProviderDatabase extends IDatabase { components: PoWChallengeComponents, difficulty: number, providerSignature: string, - ipAddress: string, + ipAddress: bigint, headers: RequestHeaders, serverChecked?: boolean, userSubmitted?: boolean, @@ -495,7 +548,21 @@ export interface IProviderDatabase extends IDatabase { getClientRecord(account: string): Promise; - storeSessionRecord(sessionRecord: SessionRecord): Promise; + storeSessionRecord(sessionRecord: Session): Promise; + + checkAndRemoveSession(sessionId: string): Promise; + + getIPBlockRuleRecord( + ipAddress: bigint, + dappAccount?: string, + ): Promise; + + storeIPBlockRuleRecords(rules: IPAddressBlockRule[]): Promise; + + getUserBlockRuleRecord( + userAccount: string, + dappAccount: string, + ): Promise; - checkAndRemoveSession(sessionId: string): Promise; + storeUserBlockRuleRecords(rules: UserAccountBlockRule[]): Promise; } diff --git a/packages/types/package.json b/packages/types/package.json index 8fdd2adc61..d410c3d939 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -40,6 +40,7 @@ "@polkadot/util": "12.6.2", "@prosopo/common": "2.1.7", "@prosopo/locale": "2.1.7", + "ip-address": "10.0.1", "scale-ts": "1.6.0", "zod": "3.23.8" }, @@ -48,8 +49,8 @@ "@types/node": "22.5.5", "@vitest/coverage-v8": "2.1.1", "concurrently": "9.0.1", - "npm-run-all": "4.1.5", "del-cli": "6.0.0", + "npm-run-all": "4.1.5", "tslib": "2.7.0", "tsx": "4.19.1", "typescript": "5.6.2", diff --git a/packages/types/src/provider/api.ts b/packages/types/src/provider/api.ts index 466ddb2690..6d33df6b57 100644 --- a/packages/types/src/provider/api.ts +++ b/packages/types/src/provider/api.ts @@ -11,12 +11,15 @@ // 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 type { Address4, Address6 } from "ip-address"; import { type ZodDefault, type ZodNumber, type ZodObject, type ZodOptional, array, + coerce, type input, number, object, @@ -48,6 +51,8 @@ import { export const ApiPrefix = "/v1/prosopo" as const; +export type IPAddress = Address4 | Address6; + export enum ApiPaths { GetImageCaptchaChallenge = "/v1/prosopo/provider/captcha/image", GetPowCaptchaChallenge = "/v1/prosopo/provider/captcha/pow", @@ -141,8 +146,8 @@ const createRateLimitSchemaWithDefaults = ( (schemas, [path, defaults]) => { const enumPath = path as CombinedApiPaths; schemas[enumPath] = object({ - windowMs: number().optional().default(defaults.windowMs), - limit: number().optional().default(defaults.limit), + windowMs: coerce.number().optional().default(defaults.windowMs), + limit: coerce.number().optional().default(defaults.limit), }); return schemas; @@ -220,7 +225,7 @@ export interface PendingCaptchaRequest { [ApiParams.requestHash]: string; deadlineTimestamp: number; // unix timestamp requestedAtTimestamp: number; // unix timestamp - ipAddress: string; + ipAddress: bigint; headers: RequestHeaders; }