diff --git a/.pnp.cjs b/.pnp.cjs index a010c15041..04df94b1d0 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -3507,6 +3507,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@types/koa__cors", "npm:3.3.0"],\ ["@typescript-eslint/eslint-plugin", "virtual:4f1584ad4aba8733a24be7c8aebbffafef25607f2d00f4b314cf96717145c692763628a31c2b85d4686fbb091ff21ebffa3cc337399c042c19a32b9bdb786464#npm:5.54.0"],\ ["@typescript-eslint/parser", "virtual:4f1584ad4aba8733a24be7c8aebbffafef25607f2d00f4b314cf96717145c692763628a31c2b85d4686fbb091ff21ebffa3cc337399c042c19a32b9bdb786464#npm:5.54.0"],\ + ["axios", "npm:1.7.2"],\ ["eslint", "npm:7.26.0"],\ ["eslint-config-prettier", "virtual:4f1584ad4aba8733a24be7c8aebbffafef25607f2d00f4b314cf96717145c692763628a31c2b85d4686fbb091ff21ebffa3cc337399c042c19a32b9bdb786464#npm:8.3.0"],\ ["eslint-import-resolver-node", "npm:0.3.4"],\ @@ -6505,6 +6506,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["proxy-from-env", "npm:1.1.0"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:1.7.2", {\ + "packageLocation": "./.yarn/cache/axios-npm-1.7.2-c89264f6f7-e457e2b0ab.zip/node_modules/axios/",\ + "packageDependencies": [\ + ["axios", "npm:1.7.2"],\ + ["follow-redirects", "virtual:c89264f6f79513b22a07db5e53adf77eba9e48634cf471fb55eb2e75d910809bbac48d9ce7a920c63c8ff2780624fff91866270d8acf614cbd0c4cb748a8b29a#npm:1.15.6"],\ + ["form-data", "npm:4.0.0"],\ + ["proxy-from-env", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["babylon", [\ @@ -8363,6 +8374,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ + ["npm:1.15.6", {\ + "packageLocation": "./.yarn/cache/follow-redirects-npm-1.15.6-50635fe51d-a62c378dfc.zip/node_modules/follow-redirects/",\ + "packageDependencies": [\ + ["follow-redirects", "npm:1.15.6"]\ + ],\ + "linkType": "SOFT"\ + }],\ ["virtual:a313c479c5c7e54d9ec8fbeeea69ff640f56b8989ea2dff42351a3fa5c4061fb80a52d8ede0f0826a181a216820c2d2c3f15da881e7fdf31cef1c446e42f0c45#npm:1.15.3", {\ "packageLocation": "./.yarn/__virtual__/follow-redirects-virtual-ff48ff82c1/0/cache/follow-redirects-npm-1.15.3-ca69c47b72-584da22ec5.zip/node_modules/follow-redirects/",\ "packageDependencies": [\ @@ -8375,6 +8393,19 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "debug"\ ],\ "linkType": "HARD"\ + }],\ + ["virtual:c89264f6f79513b22a07db5e53adf77eba9e48634cf471fb55eb2e75d910809bbac48d9ce7a920c63c8ff2780624fff91866270d8acf614cbd0c4cb748a8b29a#npm:1.15.6", {\ + "packageLocation": "./.yarn/__virtual__/follow-redirects-virtual-d6f128c480/0/cache/follow-redirects-npm-1.15.6-50635fe51d-a62c378dfc.zip/node_modules/follow-redirects/",\ + "packageDependencies": [\ + ["follow-redirects", "virtual:c89264f6f79513b22a07db5e53adf77eba9e48634cf471fb55eb2e75d910809bbac48d9ce7a920c63c8ff2780624fff91866270d8acf614cbd0c4cb748a8b29a#npm:1.15.6"],\ + ["@types/debug", null],\ + ["debug", null]\ + ],\ + "packagePeers": [\ + "@types/debug",\ + "debug"\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["foreground-child", [\ diff --git a/.yarn/cache/axios-npm-1.7.2-c89264f6f7-e457e2b0ab.zip b/.yarn/cache/axios-npm-1.7.2-c89264f6f7-e457e2b0ab.zip new file mode 100644 index 0000000000..5649c49528 --- /dev/null +++ b/.yarn/cache/axios-npm-1.7.2-c89264f6f7-e457e2b0ab.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec7d4003864bd411566b292ecebf268345eac8fb1de5a91ec51a00c29a9a4c69 +size 563863 diff --git a/.yarn/cache/follow-redirects-npm-1.15.6-50635fe51d-a62c378dfc.zip b/.yarn/cache/follow-redirects-npm-1.15.6-50635fe51d-a62c378dfc.zip new file mode 100644 index 0000000000..8568085135 --- /dev/null +++ b/.yarn/cache/follow-redirects-npm-1.15.6-50635fe51d-a62c378dfc.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ed39063645d8110789e5f6923717527e2009fd6abb8aaf73119276596fda9ed +size 11146 diff --git a/packages/faucet/Dockerfile b/packages/faucet/Dockerfile index 17962f2bf6..f9e3f05f54 100644 --- a/packages/faucet/Dockerfile +++ b/packages/faucet/Dockerfile @@ -16,6 +16,7 @@ WORKDIR /app RUN apk add --update --no-cache alpine-sdk linux-headers build-base gcc libusb-dev python3 py3-pip eudev-dev RUN ln -sf python3 /usr/bin/python +ENV YARN_CHECKSUM_BEHAVIOR=reset RUN yarn install && yarn run build RUN (cd packages/faucet && SKIP_BUILD=1 yarn pack-node) diff --git a/packages/faucet/README.md b/packages/faucet/README.md index e983367c06..856c6c0847 100644 --- a/packages/faucet/README.md +++ b/packages/faucet/README.md @@ -44,29 +44,35 @@ start Starts the faucet Environment variables -FAUCET_CONCURRENCY Number of distributor accounts. Defaults to 5. -FAUCET_PORT Port of the webserver. Defaults to 8000. -FAUCET_MEMO Memo for send transactions. Defaults to unset. -FAUCET_GAS_PRICE Gas price for transactions as a comma separated list. - Defaults to "0.025ucosm". -FAUCET_GAS_LIMIT Gas limit for send transactions. Defaults to 100000. -FAUCET_MNEMONIC Secret mnemonic that serves as the base secret for the - faucet HD accounts -FAUCET_PATH_PATTERN The pattern of BIP32 paths for the faucet accounts. - Must contain one "a" placeholder that is replaced with - the account index. - Defaults to the Cosmos Hub path "m/44'/118'/0'/0/a". -FAUCET_ADDRESS_PREFIX The bech32 address prefix. Defaults to "cosmos". -FAUCET_TOKENS A comma separated list of token denoms, e.g. - "uatom" or "ucosm, mstake". -FAUCET_CREDIT_AMOUNT_TKN Send this amount of TKN to a user requesting TKN. TKN is - a placeholder for the token's denom. Defaults to 10000000. -FAUCET_REFILL_FACTOR Send factor times credit amount on refilling. Defauls to 8. -FAUCET_REFILL_THRESHOLD Refill when balance gets below factor times credit amount. - Defaults to 20. -FAUCET_COOLDOWN_TIME Time (in seconds) after which an address can request - more tokens. Can be set to "0". Defaults to 24 hours - if unset or an empty string. +FAUCET_CONCURRENCY Number of distributor accounts. Defaults to 5. +FAUCET_PORT Port of the webserver. Defaults to 8000. +FAUCET_MEMO Memo for send transactions. Defaults to unset. +FAUCET_GAS_PRICE Gas price for transactions as a comma separated list. + Defaults to "0.025ucosm". +FAUCET_GAS_LIMIT Gas limit for send transactions. Defaults to 100000. +FAUCET_MNEMONIC Secret mnemonic that serves as the base secret for the + faucet HD accounts +FAUCET_PATH_PATTERN The pattern of BIP32 paths for the faucet accounts. + Must contain one "a" placeholder that is replaced with + the account index. + Defaults to the Cosmos Hub path "m/44'/118'/0'/0/a". +FAUCET_ADDRESS_PREFIX The bech32 address prefix. Defaults to "cosmos". +FAUCET_TOKENS A comma separated list of token denoms, e.g. + "uatom" or "ucosm, mstake". +FAUCET_CREDIT_AMOUNT_TKN Send this amount of TKN to a user requesting TKN. TKN is + a placeholder for the token's denom. Defaults to 10000000. +FAUCET_REFILL_FACTOR Send factor times credit amount on refilling. Defauls to 8. +FAUCET_REFILL_THRESHOLD Refill when balance gets below factor times credit amount. + Defaults to 20. +FAUCET_COOLDOWN_TIME Time (in seconds) after which an address can request + more tokens. Can be set to "0". Defaults to 24 hours + if unset or an empty string. +GOOGLE_RECAPTCHA_SECRET_KEY The secret key for validating input with the recaptcha v2 + service. If this value is set, then each call to the `/credit` + endpoint will require a valid recaptcha response string in + the JSON POST data named `recaptcha` in addition to the `denom` + and `address`. + Defaults to unset (disabled) ``` ### Faucet HD wallet @@ -134,6 +140,14 @@ curl --header "Content-Type: application/json" \ http://localhost:8000/credit ``` +### Using the faucet with Recaptcha validation enabled +``` +curl --header "Content-Type: application/json" \ + --request POST \ + --data '{"denom":"ucosm","address":"cosmos1yre6ac7qfgyfgvh58ph0rgw627rhw766y430qq", "recaptcha": "03AFcWeA6KFdGLxDQIx_UZ9Y9IMlAJyen-DkT3k..."}' \ + http://localhost:8000/credit +``` + ### Checking the faucets status The faucet provides a simple status check in the form of an http GET request. As diff --git a/packages/faucet/package.json b/packages/faucet/package.json index 20d4acf2c4..2aeb90b78d 100644 --- a/packages/faucet/package.json +++ b/packages/faucet/package.json @@ -1,10 +1,11 @@ { "name": "@cosmjs/faucet", - "version": "0.32.3", + "version": "0.32.4", "description": "The faucet", "contributors": [ "Ethan Frey ", - "Simon Warta " + "Simon Warta ", + "Nicholas Wehr " ], "license": "Apache-2.0", "bin": { @@ -48,6 +49,7 @@ "@cosmjs/stargate": "workspace:^", "@cosmjs/utils": "workspace:^", "@koa/cors": "^3.3", + "axios": "^1.7.2", "koa": "^2.13", "koa-bodyparser": "^4.3" }, diff --git a/packages/faucet/src/api/requestparser.spec.ts b/packages/faucet/src/api/requestparser.spec.ts index c8ae110c92..324d3d36db 100644 --- a/packages/faucet/src/api/requestparser.spec.ts +++ b/packages/faucet/src/api/requestparser.spec.ts @@ -3,7 +3,11 @@ import { RequestParser } from "./requestparser"; describe("RequestParser", () => { it("can process valid credit request with denom", () => { const body = { address: "abc", denom: "utkn" }; - expect(RequestParser.parseCreditBody(body)).toEqual({ address: "abc", denom: "utkn" }); + expect(RequestParser.parseCreditBody(body)).toEqual({ + address: "abc", + denom: "utkn", + recaptcha: undefined, + }); }); it("throws helpful error message when ticker is found", () => { diff --git a/packages/faucet/src/api/requestparser.ts b/packages/faucet/src/api/requestparser.ts index 2ff99d3c9d..cf29d2bb00 100644 --- a/packages/faucet/src/api/requestparser.ts +++ b/packages/faucet/src/api/requestparser.ts @@ -7,6 +7,8 @@ export interface CreditRequestBodyData { readonly denom: string; /** The recipient address */ readonly address: string; + /** The recaptcha v2 response */ + readonly recaptcha: string | undefined; } export interface CreditRequestBodyDataWithTicker { @@ -22,7 +24,7 @@ export class RequestParser { throw new HttpError(400, "Request body must be a dictionary."); } - const { address, denom, ticker } = body as any; + const { address, denom, ticker, recaptcha } = body as any; if (typeof ticker !== "undefined") { throw new HttpError(400, "The 'ticker' field was removed in CosmJS 0.23. Please use 'denom' instead."); @@ -47,6 +49,7 @@ export class RequestParser { return { address: address, denom: denom, + recaptcha: recaptcha, }; } } diff --git a/packages/faucet/src/api/webserver.ts b/packages/faucet/src/api/webserver.ts index c17b0656eb..4358bbd640 100644 --- a/packages/faucet/src/api/webserver.ts +++ b/packages/faucet/src/api/webserver.ts @@ -1,6 +1,8 @@ +import axios from "axios"; import Koa from "koa"; import cors = require("@koa/cors"); import bodyParser from "koa-bodyparser"; +import qs from "node:querystring"; import { isValidAddress } from "../addresses"; import * as constants from "../constants"; @@ -14,6 +16,15 @@ export interface ChainConstants { readonly chainId: string; } +interface RecaptchaResponse { + success: boolean; + // eslint-disable-next-line @typescript-eslint/naming-convention + challenge_ts?: string; + hostname?: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + "error-codes"?: string[]; +} + export class Webserver { private readonly api = new Koa(); private readonly addressCounter = new Map(); @@ -59,7 +70,7 @@ export class Webserver { // context.request.body is set by the bodyParser() plugin const requestBody = (context.request as any).body; const creditBody = RequestParser.parseCreditBody(requestBody); - const { address, denom } = creditBody; + const { address, denom, recaptcha } = creditBody; if (!isValidAddress(address, constants.addressPrefix)) { throw new HttpError(400, "Address is not in the expected format for this chain."); @@ -82,6 +93,28 @@ export class Webserver { throw new HttpError(422, `Token is not available. Available tokens are: ${availableTokens}`); } + // if enabled, require recaptcha validation + if (process.env.GOOGLE_RECAPTCHA_SECRET_KEY !== undefined) { + const response = await axios.post( + "https://www.google.com/recaptcha/api/siteverify", + qs.stringify({ + secret: process.env.GOOGLE_RECAPTCHA_SECRET_KEY, + response: recaptcha, + }), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }, + ); + + const verifyData = response.data; + if (!verifyData.success) { + console.error(`recaptcha validation FAILED ${JSON.stringify(verifyData, null, 4)}`); + throw new HttpError(423, `Recaptcha failed to verify`); + } + } + try { // Count addresses to prevent draining this.addressCounter.set(address, new Date()); diff --git a/packages/faucet/webpack.node.config.js b/packages/faucet/webpack.node.config.js index 94b875c41a..790c980888 100644 --- a/packages/faucet/webpack.node.config.js +++ b/packages/faucet/webpack.node.config.js @@ -12,7 +12,7 @@ module.exports = [ path: distdir, filename: "cli.js", library: { - type: "commonjs", + type: "commonjs2", }, }, plugins: [], diff --git a/yarn.lock b/yarn.lock index 867ad54d77..2f2be57790 100644 --- a/yarn.lock +++ b/yarn.lock @@ -589,6 +589,7 @@ __metadata: "@types/koa__cors": ^3.3 "@typescript-eslint/eslint-plugin": ^5.54.0 "@typescript-eslint/parser": ^5.54.0 + axios: ^1.7.2 eslint: ^7.5 eslint-config-prettier: ^8.3.0 eslint-import-resolver-node: ^0.3.4 @@ -2481,6 +2482,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.7.2": + version: 1.7.2 + resolution: "axios@npm:1.7.2" + dependencies: + follow-redirects: ^1.15.6 + form-data: ^4.0.0 + proxy-from-env: ^1.1.0 + checksum: e457e2b0ab748504621f6fa6609074ac08c824bf0881592209dfa15098ece7e88495300e02cd22ba50b3468fd712fe687e629dcb03d6a3f6a51989727405aedf + languageName: node + linkType: hard + "babylon@npm:^6.18.0": version: 6.18.0 resolution: "babylon@npm:6.18.0" @@ -4021,6 +4033,16 @@ __metadata: languageName: node linkType: hard +"follow-redirects@npm:^1.15.6": + version: 1.15.6 + resolution: "follow-redirects@npm:1.15.6" + peerDependenciesMeta: + debug: + optional: true + checksum: a62c378dfc8c00f60b9c80cab158ba54e99ba0239a5dd7c81245e5a5b39d10f0c35e249c3379eae719ff0285fff88c365dd446fab19dee771f1d76252df1bbf5 + languageName: node + linkType: hard + "foreground-child@npm:^2.0.0": version: 2.0.0 resolution: "foreground-child@npm:2.0.0"