Skip to content

Commit

Permalink
chore(captcha): init module folder
Browse files Browse the repository at this point in the history
  • Loading branch information
ABCxFF committed Oct 16, 2024
1 parent bc0eabd commit e5161d8
Show file tree
Hide file tree
Showing 12 changed files with 633 additions and 0 deletions.
250 changes: 250 additions & 0 deletions deno.lock

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions modules/captcha/actors/throttle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ActorBase, ActorContext, Empty } from "../module.gen.ts";
import { ThrottleRequest, ThrottleResponse } from "../utils/types.ts";

type Input = undefined;

interface State {
start: number;
count: number;
}

export class Actor extends ActorBase<Input, State> {
public initialize(_ctx: ActorContext): State {
// Will refill on first call of `throttle`
return {
start: 0,
count: 0,
};
}

throttle(_ctx: ActorContext, req: ThrottleRequest): ThrottleResponse {
const now = Date.now();

if (now - this.state.start > req.period) {
this.state.start = now;
this.state.count = 1;
return { success: true };
}

if (this.state.count >= req.requests) {
return { success: false };
}

this.state.count += 1;

return { success: true };
}

reset(_ctx: ActorContext, req: Empty): Empty {
this.state.start = 0;
this.state.count = 0;

return {};
}
}
34 changes: 34 additions & 0 deletions modules/captcha/module.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"status": "stable",
"name": "Captcha",
"description": "",
"icon": "",
"tags": [],
"authors": [
"rivet-gg",
"ABCxFF"
],
"scripts": {
"verify_captcha_token": {
"name": "Verify Captcha Response",
"public": false
},
"guard": {
"name": "Ratelimit Guarded with Captcha Challenge",
"public": false
}
},
"errors": {
"captcha_failed": {
"name": "Captcha Challenge Failed",
"internal": false
},
"captcha_needed": {
"name": "Captcha Required (Rate Limit Exceeded)",
"internal": false
}
},
"actors": {
"throttle": {}
}
}
54 changes: 54 additions & 0 deletions modules/captcha/scripts/guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { RuntimeError, ScriptContext } from "../module.gen.ts";
import { getPublicConfig } from "../utils/get_sitekey.ts";
// import { getPublicConfig } from "../utils/get_sitekey.ts";
import type { CaptchaProvider, ThrottleRequest, ThrottleResponse } from "../utils/types.ts";

export interface Request {
type: string;
key: string;
requests: number;
period: number;
captchaToken?: string | null,
captchaProvider: CaptchaProvider
}

export type Response = Record<string, never>;

export async function run(
ctx: ScriptContext,
req: Request,
): Promise<Response> {
const key = `${JSON.stringify(req.type)}.${JSON.stringify(req.key)}`;

if (req.captchaToken) {
try {
await ctx.modules.captcha.verifyCaptchaToken({
token: req.captchaToken,
provider: req.captchaProvider
});

await ctx.actors.throttle.getOrCreateAndCall<undefined, {}, {}>(key, undefined, "reset", {});

return {};
} catch {
// If we error, it means the captcha failed, we can continue with our normal ratelimitting
}
}

const res = await ctx.actors.throttle.getOrCreateAndCall<
undefined,
ThrottleRequest,
ThrottleResponse
>(key, undefined, "throttle", {
requests: req.requests,
period: req.period,
});

if (!res.success) {
throw new RuntimeError("captcha_needed", {
meta: getPublicConfig(req.captchaProvider)
});
}

return {};
}
36 changes: 36 additions & 0 deletions modules/captcha/scripts/verify_captcha_token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { RuntimeError, ScriptContext } from "../module.gen.ts";
import { validateHCaptchaResponse } from "../utils/providers/hcaptcha.ts";
import { validateCFTurnstileResponse } from "../utils/providers/turnstile.ts";
// import { validateHCaptchaResponse } from "../providers/hcaptcha.ts";
// import { validateCFTurnstileResponse } from "../providers/turnstile.ts";
import { CaptchaProvider } from "../utils/types.ts";

export interface Request {
token: string,
provider: CaptchaProvider
}

export type Response = Record<string, never>;

export async function run(
ctx: ScriptContext,
req: Request,
): Promise<Response> {
const captchaToken = req.token;
const captchaProvider = req.provider;

let success: boolean = false;
if ("hcaptcha" in captchaProvider) {
success = await validateHCaptchaResponse(captchaProvider.hcaptcha.secret, captchaToken);
} else if ("turnstile" in captchaProvider) {
success = await validateCFTurnstileResponse(captchaProvider.turnstile.secret, captchaToken);
} else {
success = true;
}

if (!success) {
throw new RuntimeError("captcha_failed");
}

return {};
}
45 changes: 45 additions & 0 deletions modules/captcha/tests/e2e_guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { test, TestContext } from "../module.gen.ts";
import { assertEquals } from "https://deno.land/[email protected]/assert/mod.ts";

const didFail = async (x: () => Promise<void>) => {
try {
await x();
return false
} catch {
return true;
}
}

test("e2e success and failure", async (ctx: TestContext) => {
const PERIOD = 5000;
const REQUESTS = 5;

const captchaProvider = {
turnstile: {
secret: "0x0000000000000000000000000000000000000000",
sitekey: "" // doesn't really matter here
}
}

assertEquals(false, await didFail(async () => {
for (let i = 0; i < REQUESTS; ++i) {
await ctx.modules.captcha.guard({
type: "ip",
key: "aaaa",
requests: REQUESTS,
period: PERIOD,
captchaProvider
});
}
}));

assertEquals(true, await didFail(async () => {
await ctx.modules.captcha.guard({
type: "ip",
key: "aaaa",
requests: REQUESTS,
period: PERIOD,
captchaProvider
});
}));
});
75 changes: 75 additions & 0 deletions modules/captcha/tests/e2e_verify_token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { test, TestContext } from "../module.gen.ts";
import { assertEquals } from "https://deno.land/[email protected]/assert/mod.ts";

const didFail = async (x: () => Promise<void>) => {
try {
await x();
return false
} catch {
return true;
}
}

test(
"hcaptcha success and failure",
async (ctx: TestContext) => {
const shouldBeFalse = await didFail(async () => {
await ctx.modules.captcha.verifyCaptchaToken({
provider: {
hcaptcha: {
secret: "0x0000000000000000000000000000000000000000",
sitekey: "" // doesn't really matter here
}
},
token: "10000000-aaaa-bbbb-cccc-000000000001"
});
});
assertEquals(shouldBeFalse, false);

const shouldBeTrue = await didFail(async () => {
await ctx.modules.captcha.verifyCaptchaToken({
provider: {
hcaptcha: {
secret: "0x0000000000000000000000000000000000000000",
sitekey: "" // doesn't really matter here
}
},
token: "lorem"
});
});
assertEquals(shouldBeTrue, true);
},
);

test(
"turnstile success and failure",
async (ctx: TestContext) => {
// Always passes
const shouldBeTrue = await didFail(async () => {
await ctx.modules.captcha.verifyCaptchaToken({
provider: {
turnstile: {
secret: "2x0000000000000000000000000000000AA",
sitekey: "" // doesn't really matter here
}
},
token: "lorem"
});
});
assertEquals(shouldBeTrue, true);

// Always fails
const shouldBeFalse = await didFail(async () => {
await ctx.modules.captcha.verifyCaptchaToken({
provider: {
turnstile: {
secret: "1x0000000000000000000000000000000AA",
sitekey: "" // doesn't really matter here
}
},
token: "ipsum"
});
});
assertEquals(shouldBeFalse, false);
},
);
19 changes: 19 additions & 0 deletions modules/captcha/utils/get_sitekey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { CaptchaProvider, PublicCaptchaProviderConfig } from "./types.ts";

export const getPublicConfig = (provider: CaptchaProvider): PublicCaptchaProviderConfig => {
if ("hcaptcha" in provider) {
return {
hcaptcha: { sitekey: provider.hcaptcha.sitekey }
};
} else if ("turnstile" in provider) {
return {
turnstile: {
sitekey: provider.turnstile.sitekey
}
}
} else {
return {
test: {}
}
}
}
21 changes: 21 additions & 0 deletions modules/captcha/utils/providers/hcaptcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const API = "https://api.hcaptcha.com/siteverify";
export const validateHCaptchaResponse = async (
secret: string,
response: string
): Promise<boolean> => {
try {
const body = new FormData();
body.append("secret", secret);
body.append("response", response);
const result = await fetch(API, {
body,
method: "POST",
});

const { success } = await result.json();

return success;
} catch {}

return false;
}
21 changes: 21 additions & 0 deletions modules/captcha/utils/providers/turnstile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const API = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
export const validateCFTurnstileResponse = async (
secret: string,
response: string
): Promise<boolean> => {
try {
const result = await fetch(API, {
body: JSON.stringify({ secret, response }),
method: "POST",
headers: {
"Content-Type": "application/json",
}
});

const { success } = await result.json();

return success;
} catch {}

return false;
}
31 changes: 31 additions & 0 deletions modules/captcha/utils/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@


interface ProviderCFTurnstile {
sitekey: string;
secret: string;
}

interface ProviderHCaptcha {
// TODO: Score threshold
sitekey: string;
secret: string;
}
type PublicCFTurnstileConfig = { sitekey: string; }
type PublicHCaptchaConfig = { sitekey: string; }

export type CaptchaProvider = { test: Record<never, never> }
| { turnstile: ProviderCFTurnstile }
| { hcaptcha: ProviderHCaptcha };

export type PublicCaptchaProviderConfig = { test: Record<never, never> }
| { turnstile: PublicCFTurnstileConfig }
| { hcaptcha: PublicHCaptchaConfig };

export interface ThrottleRequest {
requests: number;
period: number;
}

export interface ThrottleResponse {
success: boolean;
}
Loading

0 comments on commit e5161d8

Please sign in to comment.