Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(faucet) add google recaptcha v2 validation #1592

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions .yarn/cache/axios-npm-1.7.2-c89264f6f7-e457e2b0ab.zip
Git LFS file not shown
Git LFS file not shown
1 change: 1 addition & 0 deletions packages/faucet/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
60 changes: 37 additions & 23 deletions packages/faucet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions packages/faucet/package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
{
"name": "@cosmjs/faucet",
"version": "0.32.3",
"version": "0.32.4",
"description": "The faucet",
"contributors": [
"Ethan Frey <[email protected]>",
"Simon Warta <[email protected]>"
"Simon Warta <[email protected]>",
"Nicholas Wehr <[email protected]>"
],
"license": "Apache-2.0",
"bin": {
Expand Down Expand Up @@ -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"
},
Expand Down
6 changes: 5 additions & 1 deletion packages/faucet/src/api/requestparser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/faucet/src/api/requestparser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.");
Expand All @@ -47,6 +49,7 @@ export class RequestParser {
return {
address: address,
denom: denom,
recaptcha: recaptcha,
};
}
}
35 changes: 34 additions & 1 deletion packages/faucet/src/api/webserver.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string, Date>();
Expand Down Expand Up @@ -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.");
Expand All @@ -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<RecaptchaResponse>(
"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());
Expand Down
2 changes: 1 addition & 1 deletion packages/faucet/webpack.node.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module.exports = [
path: distdir,
filename: "cli.js",
library: {
type: "commonjs",
type: "commonjs2",
},
},
plugins: [],
Expand Down
22 changes: 22 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down