diff --git a/examples/verification-workers/src/debug-html.ts b/examples/verification-workers/src/debug-html.ts new file mode 100644 index 0000000..8cfa281 --- /dev/null +++ b/examples/verification-workers/src/debug-html.ts @@ -0,0 +1,729 @@ +// Copyright 2025 Cloudflare, Inc. +// +// 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 { theme } from "./index-html"; + +const directoryPath = "/.well-known/http-message-signatures-directory"; + +function escapeAttribute(value: string): string { + return value.replaceAll("&", "&").replaceAll('"', """); +} + +function turnstileScript(siteKey: string): string { + return siteKey.length > 0 + ? '' + : ""; +} + +function turnstileWidget(siteKey: string): string { + return siteKey.length > 0 + ? `
` + : '

Turnstile is not configured for this deployment.

'; +} + +const debugStyle = ``; + +export const generateDebugHTML = (turnstileSiteKey: string) => ` + + + + + Debug HTTP Message Signatures + ${turnstileScript(turnstileSiteKey)} + ${theme} + ${debugStyle} + + +
+

Debug HTTP Message Signatures

+

Validate key directories and inspect signature inputs.

+
+
+

+ This page collects debugging tools for Web Bot Auth implementations. Start by validating the key directory. +

+ +
+

Validate key directory #

+

+ Paste the full HTTPS URL for a /.well-known/http-message-signatures-directory endpoint to check whether it returns a usable directory. +

+
+ + +

URL path must end with ${directoryPath}.

+ ${turnstileWidget(turnstileSiteKey)} + +
+
+
+ +
+

Get JWK keyid #

+

+ Paste a JWK to compute its RFC 7638 SHA-256 thumbprint for use as keyid. +

+
+ + + +
+
+
+ +
+

Verify request headers #

+

+ Paste the signed request target, verification JWK, and HTTP Message Signature headers here. +

+
+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +

Accepted forms: "<url>" or <label>="<url>".

+
+ + +
+ +
+
+ + ${turnstileWidget(turnstileSiteKey)} + + +
+
+
+
+ + +`; diff --git a/examples/verification-workers/src/html.ts b/examples/verification-workers/src/index-html.ts similarity index 98% rename from examples/verification-workers/src/html.ts rename to examples/verification-workers/src/index-html.ts index 7d2e850..7e55316 100644 --- a/examples/verification-workers/src/html.ts +++ b/examples/verification-workers/src/index-html.ts @@ -12,13 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -const generateHTML = (status?: boolean) => ` - - - - - Identify Bots with HTTP Message Signatures - +`; + +const generateHTML = (status?: boolean) => ` + + + + + Identify Bots with HTTP Message Signatures + ${theme}
@@ -275,7 +277,7 @@ footer {

It's hard to debug. How can this website help?

- This website expose an endpoint dropping incoming request headers on /debug + Use the debug page to validate key directories and signature headers.

I have comments and want to contribute. Where do I go?

diff --git a/examples/verification-workers/src/index.ts b/examples/verification-workers/src/index.ts index 1e683b0..37a7793 100644 --- a/examples/verification-workers/src/index.ts +++ b/examples/verification-workers/src/index.ts @@ -24,10 +24,16 @@ import { signatureHeaders, verify, } from "web-bot-auth"; -import { invalidHTML, neutralHTML, validHTML } from "./html"; +import { generateDebugHTML } from "./debug-html"; +import { invalidHTML, neutralHTML, validHTML } from "./index-html"; +import { proxyDirectoryRequest } from "./proxy-directory"; import jwk from "../../rfc9421-keys/ed25519.json" assert { type: "json" }; import { Ed25519Signer } from "web-bot-auth/crypto"; +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + async function getExampleDirectory(): Promise { const key = { kid: await jwkToKeyID( @@ -51,7 +57,7 @@ async function fetchDirectory(signatureAgent: string): Promise { let parsed: string; try { parsed = JSON.parse(signatureAgent); - } catch (_e) { + } catch { const e = new Error( `Failed to validate Signature-Agent header: ${signatureAgent}` ); @@ -99,6 +105,7 @@ function verifyEd25519( params: VerificationParams ) => Promise { return async (data, signature, _params) => { + void _params; const key = await crypto.subtle.importKey( "jwk", directory.keys[0], @@ -146,13 +153,13 @@ async function verifySignature( directory = await getExampleDirectory(); } } catch (e) { - return SignatureValidationStatus.INVALID((e as Error).message); + return SignatureValidationStatus.INVALID(errorMessage(e)); } try { await verify(request, verifyEd25519(directory)); } catch (e) { - return SignatureValidationStatus.INVALID((e as Error).message); + return SignatureValidationStatus.INVALID(errorMessage(e)); } console.log("Signature verified successfully"); @@ -165,14 +172,13 @@ async function verifySignature( export default { async fetch(request, env, ctx): Promise { + void ctx; const url = new URL(request.url); if (url.pathname.startsWith("/debug")) { - return new Response( - [...request.headers] - .map(([key, value]) => `${key}: ${value}`) - .join("\n") - ); + return new Response(generateDebugHTML(env.TURNSTILE_SITE_KEY ?? ""), { + headers: { "content-type": "text/html; charset=utf-8" }, + }); } if (url.pathname.startsWith("/v0/api/verify")) { @@ -180,20 +186,26 @@ export default { return new Response(status); } + if (url.pathname === "/v0/api/proxy-directory") { + return proxyDirectoryRequest(request, env); + } + if (url.pathname.startsWith(HTTP_MESSAGE_SIGNATURES_DIRECTORY)) { const directory = await getExampleDirectory(); + const response = new Response(JSON.stringify(directory), { + headers: { + "content-type": MediaType.HTTP_MESSAGE_SIGNATURES_DIRECTORY, + }, + }); const signedHeaders = await directoryResponseHeaders( - request, + { request, response }, [await getSigner()], { created: new Date(), expires: new Date(Date.now() + 300_000) } ); - return new Response(JSON.stringify(directory), { - headers: { - ...signedHeaders, - "content-type": MediaType.HTTP_MESSAGE_SIGNATURES_DIRECTORY, - }, - }); + response.headers.set("Signature", signedHeaders.Signature); + response.headers.set("Signature-Input", signedHeaders["Signature-Input"]); + return response; } const status = await verifySignature(env, request); @@ -214,6 +226,7 @@ export default { }, // On a schedule, send a web-bot-auth signed request to a target endpoint async scheduled(ctx, env, ectx) { + void ectx; const headers = { "Signature-Agent": JSON.stringify(env.SIGNATURE_AGENT) }; const request = new Request(env.TARGET_URL, { headers }); const created = new Date(ctx.scheduledTime); diff --git a/examples/verification-workers/src/proxy-directory.ts b/examples/verification-workers/src/proxy-directory.ts new file mode 100644 index 0000000..eb929de --- /dev/null +++ b/examples/verification-workers/src/proxy-directory.ts @@ -0,0 +1,203 @@ +// Copyright 2025 Cloudflare, Inc. +// +// 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 { HTTP_MESSAGE_SIGNATURES_DIRECTORY } from "web-bot-auth"; + +const DIRECTORY_FETCH_TIMEOUT_MS = 5_000; +const DIRECTORY_RESPONSE_MAX_BYTES = 64_000; +const TURNSTILE_VERIFY_URL = + "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + +function getProperty(value: unknown, name: string): unknown { + if (value === null || typeof value !== "object") { + return undefined; + } + for (const [key, property] of Object.entries(value)) { + if (key === name) { + return property; + } + } + return undefined; +} + +function errorResponse(error: string, status = 400): Response { + return Response.json({ error }, { status }); +} + +function getStringFormValue( + formData: FormData, + name: string +): string | undefined { + const value = formData.get(name); + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +async function validateTurnstile( + env: Env, + token: string, + remoteIP: string | null +): Promise { + const formData = new FormData(); + formData.append("secret", env.TURNSTILE_SECRET_KEY); + formData.append("response", token); + if (remoteIP !== null) { + formData.append("remoteip", remoteIP); + } + + let response: Response; + try { + response = await fetch(TURNSTILE_VERIFY_URL, { + body: formData, + method: "POST", + }); + } catch { + return false; + } + + if (!response.ok) { + return false; + } + + try { + const body: unknown = await response.json(); + return getProperty(body, "success") === true; + } catch { + return false; + } +} + +function validateDirectoryURL(url: string): URL | string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return "URL must be valid"; + } + + if (parsed.protocol !== "https:") { + return 'Directory URL must use "https:"'; + } + if (parsed.username !== "" || parsed.password !== "") { + return "Directory URL must not include credentials"; + } + if (parsed.port !== "") { + return "Directory URL must not use a custom port"; + } + if (!parsed.pathname.endsWith(HTTP_MESSAGE_SIGNATURES_DIRECTORY)) { + return `Directory URL path must end with ${HTTP_MESSAGE_SIGNATURES_DIRECTORY}`; + } + + return parsed; +} + +async function readTextWithLimit( + response: Response, + byteLimit: number +): Promise { + if (response.body === null) { + return ""; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let bytesRead = 0; + let text = ""; + + while (true) { + const result = await reader.read(); + if (result.done) { + return text + decoder.decode(); + } + + bytesRead += result.value.byteLength; + if (bytesRead > byteLimit) { + await reader.cancel(); + throw new Error("Directory response is too large"); + } + text += decoder.decode(result.value, { stream: true }); + } +} + +export async function proxyDirectoryRequest( + request: Request, + env: Env +): Promise { + if (request.method !== "POST") { + return errorResponse("Method not allowed", 405); + } + + const origin = request.headers.get("Origin"); + if (origin !== new URL(request.url).origin) { + return errorResponse("Bad request"); + } + + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return errorResponse("Request body must be form data"); + } + + const url = getStringFormValue(formData, "url"); + const turnstileToken = getStringFormValue(formData, "cf-turnstile-response"); + if (url === undefined || url.length === 0) { + return errorResponse("Missing url"); + } + if (turnstileToken === undefined || turnstileToken.length === 0) { + return errorResponse("Missing Turnstile token"); + } + + const parsed = validateDirectoryURL(url); + if (typeof parsed === "string") { + return errorResponse(parsed); + } + + const turnstileOK = await validateTurnstile( + env, + turnstileToken, + request.headers.get("CF-Connecting-IP") + ); + if (!turnstileOK) { + return errorResponse("Turnstile verification failed", 403); + } + + let response: Response; + try { + response = await fetch(parsed.toString(), { + redirect: "manual", + signal: AbortSignal.timeout(DIRECTORY_FETCH_TIMEOUT_MS), + }); + } catch { + return errorResponse("Directory fetch failed", 502); + } + + if (!response.ok) { + return errorResponse(`Directory returned HTTP ${response.status}`, 502); + } + + let text: string; + try { + text = await readTextWithLimit(response, DIRECTORY_RESPONSE_MAX_BYTES); + } catch { + return errorResponse("Directory response is too large", 502); + } + + return new Response(text, { + headers: { + "Access-Control-Allow-Origin": origin, + "Content-Type": + response.headers.get("Content-Type") ?? "application/json", + }, + }); +} diff --git a/examples/verification-workers/test/index.spec.ts b/examples/verification-workers/test/index.spec.ts index 2e1950b..f4fbb63 100644 --- a/examples/verification-workers/test/index.spec.ts +++ b/examples/verification-workers/test/index.spec.ts @@ -19,7 +19,7 @@ import { waitOnExecutionContext, SELF, } from "cloudflare:test"; -import { describe, it, expect } from "vitest"; +import { afterEach, describe, it, expect, vi } from "vitest"; import worker from "../src/index"; // For now, you'll need to do something like this to get a correctly-typed @@ -27,6 +27,49 @@ import worker from "../src/index"; const IncomingRequest = Request; const sampleURL = "https://example.com"; +const proxyURL = `${sampleURL}/v0/api/proxy-directory`; +const directoryURL = `${sampleURL}/.well-known/http-message-signatures-directory`; +const turnstileVerifyURL = + "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + +const validatorHeaders = { + "CF-Connecting-IP": "192.0.2.1", + Origin: sampleURL, +}; + +function validatorRequest(body: Record): Request { + const formData = new FormData(); + for (const [name, value] of Object.entries(body)) { + formData.append(name, value); + } + return new IncomingRequest(proxyURL, { + body: formData, + headers: validatorHeaders, + method: "POST", + }); +} + +function mockedFetch(targetResponse: Response): ReturnType { + return vi.fn((input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url === turnstileVerifyURL) { + return Promise.resolve(Response.json({ success: true })); + } + return Promise.resolve(targetResponse.clone()); + }); +} + +async function fetchValidator(body: Record): Promise { + const ctx = createExecutionContext(); + const response = await worker.fetch(validatorRequest(body), env, ctx); + await waitOnExecutionContext(ctx); + return response; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); describe("/ endpoint", () => { it("responds with HTTP 200", async () => { @@ -36,16 +79,295 @@ describe("/ endpoint", () => { await waitOnExecutionContext(ctx); expect(response.status).toEqual(200); }); + + it("does not render the directory validator", async () => { + const request = new IncomingRequest(sampleURL); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + + expect(await response.text()).not.toContain("directory-validator"); + }); }); describe("/debug endpoint", () => { - it("responds with request headers", async () => { - const headers = { test: "this is a test header" }; - const request = new Request(`${sampleURL}/debug`, { headers }); + it("responds with HTML", async () => { + const request = new Request(`${sampleURL}/debug`); const response = await SELF.fetch(request); - const headersString = Object.entries(headers) - .map(([k, v]) => `${k}: ${v}`) - .join("\n"); - expect(await response.text()).toMatch(headersString); + + expect(response.status).toEqual(200); + expect(response.headers.get("content-type")).toContain("text/html"); + }); + + it("renders directory validation tools", async () => { + const request = new Request(`${sampleURL}/debug`); + const response = await SELF.fetch(request); + const body = await response.text(); + + expect(body).toContain("directory-validator"); + expect(body).toContain("Validate key directory"); + expect(body).toContain( + "URL path must end with /.well-known/http-message-signatures-directory" + ); + expect(body).toContain("Fetched directory"); + expect(body).toContain("fillJWK(key)"); + expect(body).not.toContain('data-sitekey=""'); + }); + + it("renders section fragment tooling", async () => { + const request = new Request(`${sampleURL}/debug`); + const response = await SELF.fetch(request); + const body = await response.text(); + + expect(body).toContain("section-link"); + expect(body).toContain('data-section="validate-directory"'); + expect(body).toContain('data-section="get-jwk-keyid"'); + expect(body).toContain('data-section="verify-request-headers"'); + expect(body).toContain("fragmentParams"); + expect(body).toContain('prefill("key-id-jwk", "key-id-jwk")'); + expect(body).toContain('prefill("signature-jwk", "signature-jwk")'); + expect(body).toContain('prefill("key-id-jwk", "jwk")'); + expect(body).toContain('prefill("signature-jwk", "jwk")'); + expect(body).toContain('["section", section]'); + expect(body).toContain('["key-id-jwk", formValue("key-id-jwk")]'); + expect(body).toContain('["signature-jwk", formValue("signature-jwk")]'); + expect(body).toContain("currentSectionURL"); + expect(body).toContain("updateSectionLink"); + expect(body).toContain('link.addEventListener("pointerenter"'); + expect(body).toContain('link.addEventListener("focus"'); + expect(body).toContain("url.hash = params.toString()"); + expect(body).toContain("history.pushState"); + }); + + it("renders JWK keyid tools", async () => { + const request = new Request(`${sampleURL}/debug`); + const response = await SELF.fetch(request); + const body = await response.text(); + + expect(body).toContain("Get JWK keyid"); + expect(body).toContain("key-id-calculator"); + expect(body).toContain("computeJWKKeyID"); + expect(body).toContain("signatureJWK.value"); + }); + + it("renders signature header placeholders", async () => { + const request = new Request(`${sampleURL}/debug`); + const response = await SELF.fetch(request); + const body = await response.text(); + + expect(body).toContain("Verify request headers"); + expect(body).toContain("signature-jwk"); + expect(body).not.toContain("signature-directory-url"); + expect(body).toContain("Method"); + expect(body).toContain("URL"); + expect(body).toContain("Signature"); + expect(body).toContain("Signature-Agent"); + expect(body).toContain("Signature-Input"); + expect(body).toContain("Accepted forms:"); + expect(body).toContain( + 'Signature-Agent must be "" or