Skip to content

Commit c8d0ec0

Browse files
authored
Update Cloudflare as-a-library docs (#133)
1 parent 406228d commit c8d0ec0

File tree

16 files changed

+152
-57
lines changed

16 files changed

+152
-57
lines changed

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# reCAPTCHA WAF (Edge Compute) Library
1+
# reCAPTCHA Edge Compute Library
22

33
[![Build and Test Core Library](https://github.com/GoogleCloudPlatform/recaptcha-edge/actions/workflows/build_core.yml/badge.svg)](https://github.com/GoogleCloudPlatform/recaptcha-edge/actions/workflows/build_core.yml)
44
[![Build and Test Akamai Binding](https://github.com/GoogleCloudPlatform/recaptcha-edge/actions/workflows/build_akamai.yml/badge.svg)](https://github.com/GoogleCloudPlatform/recaptcha-edge/actions/workflows/build_akamai.yml)
@@ -29,9 +29,14 @@ Typically, this involves:
2929
Please see the [reCAPTCHA Google Cloud Documentation](https://cloud.google.com/recaptcha/docs) for more details on each step.
3030

3131
### As a Library
32-
This package has not yet been added to the NPM package repository, and must be manually imported.
32+
Each platform has their own NPM package. Bindings that are hosted on NPM include:
33+
* [@google-cloud/recaptcha-cloudflare](https://www.npmjs.com/package/@google-cloud/recaptcha-cloudflare?activeTab=readme)
3334

34-
Please see the examples for each binding in the [bindings](https://github.com/GoogleCloudPlatform/recaptcha-edge/tree/main/bindings) directory of choice.
35+
Bindings that are not yet hosted on NPM should be [downloaded and installed locally](https://docs.npmjs.com/downloading-and-installing-packages-locally).
36+
37+
The base package is available on NPM as [@google-cloud/recaptcha-edge](https://www.npmjs.com/package/@google-cloud/recaptcha-edge) and is intended as an abstraction layer for implementing additional platforms. Platform-specific packages should be used if possible.
38+
39+
Please see the examples and documentation for each binding in the [bindings](https://github.com/GoogleCloudPlatform/recaptcha-edge/tree/main/bindings) directory of choice.
3540

3641
## Contribution
3742

bindings/akamai/src/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
EdgeRequestInit,
2626
Assessment,
2727
ListFirewallPoliciesResponse,
28-
CHALLENGE_PAGE_URL
28+
CHALLENGE_PAGE_URL,
2929
} from "@google-cloud/recaptcha-edge";
3030
import { httpRequest, HttpResponse } from "http-request";
3131
import { TextDecoder, TextEncoder } from "encoding";
@@ -102,7 +102,7 @@ async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
102102

103103
const RECAPTCHA_JS = "https://www.google.com/recaptcha/enterprise.js";
104104
// Firewall Policies API is currently only available in the public preview.
105-
const DEFAULT_RECAPTCHA_ENDPOINT = "https://public-preview-recaptchaenterprise.googleapis.com";
105+
const POLICY_RECAPTCHA_ENDPOINT = "https://public-preview-recaptchaenterprise.googleapis.com";
106106

107107
// Some headers aren't safe to forward from the origin response through an
108108
// EdgeWorker on to the client For more information see the tech doc on
@@ -433,14 +433,19 @@ export class AkamaiContext extends RecaptchaContext {
433433
}
434434

435435
export function recaptchaConfigFromRequest(request: EW.ResponseProviderRequest): RecaptchaConfig {
436+
const has_policy_keys =
437+
request.getVariable("PMUSER_RECAPTCHAACTIONSITEKEY") ||
438+
request.getVariable("PMUSER_RECAPTCHASESSIONSITEKEY") ||
439+
request.getVariable("PMUSER_RECAPTCHACHALLENGESITEKEY");
436440
return {
437441
projectNumber: parseInt(request.getVariable("PMUSER_GCPPROJECTNUMBER") || "0", 10),
438442
apiKey: request.getVariable("PMUSER_GCPAPIKEY") || "",
439443
actionSiteKey: request.getVariable("PMUSER_RECAPTCHAACTIONSITEKEY") || "",
440444
expressSiteKey: request.getVariable("PMUSER_RECAPTCHAEXPRESSSITEKEY") || "",
441445
sessionSiteKey: request.getVariable("PMUSER_RECAPTCHASESSIONSITEKEY") || "",
442446
challengePageSiteKey: request.getVariable("PMUSER_RECAPTCHACHALLENGESITEKEY") || "",
443-
recaptchaEndpoint: request.getVariable("PMUSER_RECAPTCHAENDPOINT") || DEFAULT_RECAPTCHA_ENDPOINT,
447+
recaptchaEndpoint:
448+
request.getVariable("PMUSER_RECAPTCHAENDPOINT") || (has_policy_keys ? POLICY_RECAPTCHA_ENDPOINT : undefined),
444449
debug: request.getVariable("PMUSER_DEBUG") === "true",
445450
unsafe_debug_dump_logs: request.getVariable("PMUSER_UNSAFE_DEBUG_DUMP_LOGS") === "true",
446451
sessionJsInjectPath: request.getVariable("PMUSER_SESSION_JS_INSTALL_PATH"),

bindings/cloudflare/README.md

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,91 @@ To integrate this package with an existing Cloudflare account:
6464
Please see the [reCAPTCHA Google Cloud Documentation](https://cloud.google.com/recaptcha/docs) for more details on each step.
6565

6666
### As a Library
67-
This package has not yet been added to the NPM package repository, and must be manually imported.
67+
This package has been added to the NPM package repo as [@google-cloud/recaptcha-cloudflare](https://www.npmjs.com/package/@google-cloud/recaptcha-cloudflare?activeTab=readme).
68+
69+
This library supports a standard reCAPTCHA v2 or v3 workflow, and is intended to be used on the [Cloudflare Worker](https://developers.cloudflare.com/workers/) edge compute platform. To use this library, first create a `CloudflareContext` object. This object
70+
can be initialized with [Cloudflare Environment Variables](https://developers.cloudflare.com/workers/configuration/environment-variables/) or
71+
inline constants:
72+
```js
73+
import {
74+
CloudflareContext,
75+
recaptchaConfigFromEnv,
76+
createAssessment
77+
} from "@google-cloud/recaptcha-cloudflare";
78+
79+
export default {
80+
async fetch(request, env, ctx): Promise<Response> {
81+
// initialized from Cloudflare Environment
82+
const rcctx = new CloudflareContext(env, ctx, recaptchaConfigFromEnv(env));
83+
// OR: initialized inline
84+
const rcctx = new CloudflareContext(env, ctx, {projectNumber: 12345, apiKey: "abcd", enterpriseSiteKey: "6Labcdefg"});
85+
86+
... // do further processing
87+
},
88+
} satisfies ExportedHandler<Env>;
89+
```
90+
91+
This context can then be used to call reCAPTCHA's `CreateAssessment` on the incoming Request:
92+
```js
93+
...
94+
const assessment = await createAssessment(rcctx, request);
95+
...
96+
```
97+
98+
Most common request data expected in [`CreateAssessment`](https://cloud.google.com/recaptcha/docs/reference/rest/v1/projects.assessments/create) will be automatically populated when calling the `createAssessment` function, including:
99+
* userAgent
100+
* userIpAddress
101+
* requestedUri
102+
* ja3 or ja4
103+
* headers
104+
105+
The Project number and API Key set when creating the `CloudflareContext` will be used to form the correct `CreateAssessment` endpoint URL. The following URL format will be used: `https://recaptchaenterprise.googleapis.com/v1/projects/{projectNumber}/assessments??key={apikey}`.
106+
The `enterpriseSiteKey` set when creating the `CloudflareContext` will be used to populate the Assessment event.
107+
108+
The user's reCAPTCHA token may be automatically extracted from the incoming request body under the following conditions:
109+
* The incoming requests uses a POST HTTP method
110+
* The content type is `application/json` or `application/x-www-form-urlencoded` or `multipart/form-data`
111+
* The token is expected to reside in the `g-recaptcha-response` field.
112+
113+
If all of these cases are true, simply call createAssessment:
114+
```js
115+
...
116+
const assessment = await createAssessment(rcctx, request);
117+
...
118+
```
119+
120+
If one or more of the token format cases are false, the token must be manually extracted and passed as an additional parameter:
121+
```js
122+
...
123+
const token = manuallyExtractToken(request); // You must define this function.
124+
const assessment = await createAssessment(rcctx, request, {token});
125+
...
126+
```
127+
128+
The `expectedAction` parameter is not automatically populated, and should be populated if applicable.
129+
```js
130+
const assessment = await createAssessment(rcctx, request, {expectedAction: "login"});
131+
```
132+
See the official documentation on [action names](https://cloud.google.com/recaptcha/docs/actions-website).
133+
134+
It is important that createAssessment is only called on paths where you expect a user to pass a token.
135+
```js
136+
import {
137+
CloudflareContext,
138+
createAssessment,
139+
pathMatch,
140+
} from "@google-cloud/recaptcha-cloudflare";
141+
142+
...
143+
if (pathMatch(request, "/login", "POST")) {
144+
const assessment = await createAssessment(rcctx, request, {expectedAction: "login"});
145+
... // check assessment results, such as score.
146+
}
147+
...
148+
```
149+
This will avoid the added latency from the CreateAssessment RPC, and reduce billing events.
68150

69-
Please see the examples in the [examples](https://github.com/GoogleCloudPlatform/recaptcha-edge/tree/main/bindings/cloudflare/examples) directory.
151+
For complete end-to-end examples, see the [examples](https://github.com/GoogleCloudPlatform/recaptcha-edge/tree/main/bindings/cloudflare/examples) directory.
70152

71153
## Contribution
72154

bindings/cloudflare/examples/account-defender/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ async function recaptchaLoginAccountVerdict(rcctx: CloudflareContext, request: R
5555
if (!token || !username) {
5656
return "block";
5757
}
58-
const assessment = await createAssessment(rcctx, request, undefined, {userInfo: {accountId: username}});
58+
const assessment = await createAssessment(rcctx, request, {userInfo: {accountId: username}});
5959
// Block all requests that Account Defender identifies as 'suspicious login activity'.
6060
if ((assessment.accountDefenderAssessment?.labels ?? []).includes("SUSPICIOUS_LOGIN_ACTIVITY")) {
6161
return "block";

bindings/cloudflare/examples/simple-block/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
* A simple example on how to use the reCAPTCHA Cloudflare library to
1919
* make decisions based on score.
2020
*
21-
* This example calls Create assessment based on configuration context from CloudFlare's Env,
21+
* This example calls Create assessment based on configuration context from CloudFlare's Env,
2222
* and automatically extracted information from the incoming request.
2323
*/
2424

bindings/cloudflare/src/cloudflare_worker.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,14 @@ test("nomatch-ok", async () => {
4949
parsedBody.assessmentEnvironment.version = undefined;
5050
let expected = {
5151
event: {
52+
firewallPolicyEvaluation: true,
5253
token: "action-token",
5354
siteKey: "action-site-key",
5455
wafTokenAssessment: true,
5556
userIpAddress: "1.2.3.4",
5657
headers: ["cf-connecting-ip:1.2.3.4", "user-agent:test-user-agent", "x-recaptcha-token:action-token"],
5758
requestedUri: "http://example.com/condition/blockifscorelow",
5859
userAgent: "test-user-agent",
59-
firewallPolicyEvaluation: true,
6060
},
6161
assessmentEnvironment: {
6262
client: "@google-cloud/recaptcha-cloudflare",

bindings/cloudflare/src/context.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ type Env = any;
1818

1919
const RECAPTCHA_JS = "https://www.google.com/recaptcha/enterprise.js";
2020
// Firewall Policies API is currently only available in the public preview.
21-
const DEFAULT_RECAPTCHA_ENDPOINT = "https://public-preview-recaptchaenterprise.googleapis.com";
21+
const POLICY_RECAPTCHA_ENDPOINT = "https://public-preview-recaptchaenterprise.googleapis.com";
2222

2323
// eslint-disable-next-line @typescript-eslint/no-unused-vars
2424
import {
@@ -127,6 +127,7 @@ export class CloudflareContext extends RecaptchaContext {
127127
}
128128

129129
export function recaptchaConfigFromEnv(env: Env): RecaptchaConfig {
130+
const has_policy_keys = env.ACTION_SITE_KEY || env.SESSION_SITE_KEY || env.CHALLENGE_PAGE_SITE_KEY;
130131
return {
131132
projectNumber: env.PROJECT_NUMBER,
132133
apiKey: env.API_KEY,
@@ -135,7 +136,7 @@ export class CloudflareContext extends RecaptchaContext {
135136
sessionSiteKey: env.SESSION_SITE_KEY,
136137
challengePageSiteKey: env.CHALLENGE_PAGE_SITE_KEY,
137138
enterpriseSiteKey: env.ENTERPRISE_SITE_KEY,
138-
recaptchaEndpoint: env.RECAPTCHA_ENDPOINT ?? DEFAULT_RECAPTCHA_ENDPOINT,
139+
recaptchaEndpoint: env.RECAPTCHA_ENDPOINT ?? (has_policy_keys ? POLICY_RECAPTCHA_ENDPOINT : undefined),
139140
sessionJsInjectPath: env.SESSION_JS_INSTALL_PATH,
140141
credentialPath: env.CREDENTIAL_PATH,
141142
accountId: env.USER_ACCOUNT_ID,

bindings/cloudflare/src/wrappers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,10 @@ export function pathMatch(req: Request, patterns: string | [string], method?: st
4646
export function createAssessment(
4747
ctx: CloudflareContext,
4848
r: Request,
49-
environment?: [string, string],
5049
additionalParams?: Event,
50+
environment?: [string, string],
5151
): Promise<Assessment> {
52-
return callCreateAssessment(ctx, new FetchApiRequest(r), environment, additionalParams);
52+
return callCreateAssessment(ctx, new FetchApiRequest(r), additionalParams, environment);
5353
}
5454

5555
export function listFirewallPolicies(ctx: CloudflareContext): Promise<ListFirewallPoliciesResponse> {

bindings/cloudflare/wrangler.test.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,4 @@ ACTION_SITE_KEY = "action-site-key"
1010
EXPRESS_SITE_KEY = "express-site-key"
1111
SESSION_SITE_KEY = "session-site-key"
1212
CHALLENGE_PAGE_SITE_KEY = "challenge-page-site-key"
13-
ENTERPRISE_SITE_KEY = "enterprise-site-key"
1413
RECAPTCHA_ENDPOINT = "https://recaptchaenterprise.googleapis.com"

bindings/fastly/src/context.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { Dictionary } from "fastly:dictionary";
2020

2121
const RECAPTCHA_JS = "https://www.google.com/recaptcha/enterprise.js";
2222
// Firewall Policies API is currently only available in the public preview.
23-
const DEFAULT_RECAPTCHA_ENDPOINT = "https://public-preview-recaptchaenterprise.googleapis.com";
23+
const POLICY_RECAPTCHA_ENDPOINT = "https://public-preview-recaptchaenterprise.googleapis.com";
2424

2525
import {
2626
RecaptchaConfig,
@@ -222,6 +222,8 @@ export function recaptchaConfigFromConfigStore(name: string): RecaptchaConfig {
222222
throw new InitError('Failed to open Fastly config store: "' + name + '". ' + JSON.stringify(e));
223223
}
224224
}
225+
const has_policy_keys =
226+
cfg.get("action_site_key") || cfg.get("session_site_key") || cfg.get("challengepage_site_key");
225227
return {
226228
projectNumber: Number(cfg.get("project_number")),
227229
apiKey: cfg.get("api_key") ?? "",
@@ -230,7 +232,7 @@ export function recaptchaConfigFromConfigStore(name: string): RecaptchaConfig {
230232
sessionSiteKey: cfg.get("session_site_key") ?? undefined,
231233
challengePageSiteKey: cfg.get("challengepage_site_key") ?? undefined,
232234
enterpriseSiteKey: cfg.get("enterprise_site_key") ?? undefined,
233-
recaptchaEndpoint: cfg.get("recaptcha_endpoint") ?? DEFAULT_RECAPTCHA_ENDPOINT,
235+
recaptchaEndpoint: cfg.get("recaptcha_endpoint") ?? (has_policy_keys ? POLICY_RECAPTCHA_ENDPOINT : undefined),
234236
sessionJsInjectPath: cfg.get("session_js_install_path") ?? undefined,
235237
debug: (cfg.get("debug") ?? "false") == "true",
236238
unsafe_debug_dump_logs: (cfg.get("unsafe_debug_dump_logs") ?? "false") == "true",

bindings/fastly/src/wrappers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,10 @@ export async function processRequest(ctx: FastlyContext, r: Request): Promise<Re
4646
export function createAssessment(
4747
ctx: FastlyContext,
4848
r: Request,
49-
environment?: [string, string],
5049
additionalParams?: Event,
50+
environment?: [string, string],
5151
): Promise<Assessment> {
52-
return callCreateAssessment(ctx, new FetchApiRequest(r), environment, additionalParams);
52+
return callCreateAssessment(ctx, new FetchApiRequest(r), additionalParams, environment);
5353
}
5454

5555
export function listFirewallPolicies(ctx: FastlyContext): Promise<ListFirewallPoliciesResponse> {

bindings/xlb/src/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ function getConfig(): RecaptchaConfig {
2626
sessionSiteKey: process.env.SESSION_SITE_KEY || undefined,
2727
challengePageSiteKey: process.env.CHALLENGE_PAGE_SITE_KEY || undefined,
2828
enterpriseSiteKey: process.env.ENTERPRISE_SITE_KEY || undefined,
29-
recaptchaEndpoint: process.env.RECAPTCHA_ENDPOINT || "",
29+
recaptchaEndpoint: process.env.RECAPTCHA_ENDPOINT || undefined,
3030
sessionJsInjectPath: process.env.SESSION_JS_INSTALL_PATH || undefined,
3131
debug: (process.env.DEBUG ?? "false") == "true",
3232
unsafe_debug_dump_logs: false,

src/createAssessment.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,8 @@ async function getUserInfo(req: EdgeRequest, accountIdField?: string, usernameFi
124124
* Adds reCAPTCHA specific values to an Event strucutre.
125125
* This includes, the siteKey, the token, cookies, and flags like express.
126126
*/
127-
export async function createPartialEventWithSiteInfo(context: RecaptchaContext, req: EdgeRequest): Promise<Event> {
128-
const event: Event = {};
127+
export async function addTokenAndSiteKeyToEvent(context: RecaptchaContext, req: EdgeRequest, base?: Event): Promise<Event> {
128+
const event: Event = base ?? {};
129129
const actionToken = req.getHeader("X-Recaptcha-Token");
130130
if (context.config.actionSiteKey && actionToken) {
131131
// WAF action token in the header.
@@ -178,7 +178,7 @@ export async function createPartialEventWithSiteInfo(context: RecaptchaContext,
178178
}
179179

180180
if (context.config.enterpriseSiteKey && req.method === "POST") {
181-
const recaptchaToken = await getTokenFromBody(context, req);
181+
const recaptchaToken = event.token ?? await getTokenFromBody(context, req);
182182
if (recaptchaToken) {
183183
event.token = recaptchaToken;
184184
event.siteKey = context.config.enterpriseSiteKey;
@@ -222,20 +222,19 @@ export async function createPartialEventWithSiteInfo(context: RecaptchaContext,
222222
export async function callCreateAssessment(
223223
context: RecaptchaContext,
224224
req: EdgeRequest,
225-
environment?: [string, string],
226225
additionalParams?: Event,
226+
environment?: [string, string],
227227
): Promise<Assessment> {
228228
// TODO: this should use a builder pattern. with a CreateAssessmentRequest type.
229-
const site_info = await createPartialEventWithSiteInfo(context, req);
229+
const site_info = await addTokenAndSiteKeyToEvent(context, req, additionalParams);
230230
const site_features = await context.buildEvent(req);
231231
if (req.method === "POST" && new URL(req.url).pathname === context.config.credentialPath) {
232232
site_features.userInfo = await getUserInfo(req, context.config.accountId, context.config.username);
233233
}
234234

235235
const event = {
236236
...site_info,
237-
...site_features,
238-
...additionalParams,
237+
...site_features
239238
};
240239
const assessment: Assessment = { event };
241240
assessment.assessmentEnvironment = {

0 commit comments

Comments
 (0)