diff --git a/infra/console.ts b/infra/console.ts index 8ddbc43f14a6..d6663cbb3e7a 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -24,6 +24,9 @@ const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY") const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAIL") // OpenCode API key for Jev spam scoring of enterprise form submissions. const ENTERPRISE_SPAM_API_KEY = new sst.Secret("ENTERPRISE_SPAM_API_KEY") +// hCaptcha guards the enterprise form; the site key is public and baked into the client bundle. +const HCAPTCHA_SITE_KEY = new sst.Secret("HCAPTCHA_SITE_KEY") +const HCAPTCHA_SECRET_KEY = new sst.Secret("HCAPTCHA_SECRET_KEY") const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID") const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET") @@ -43,6 +46,7 @@ new sst.cloudflare.x.SolidStart("Console", { AWS_SES_SECRET_ACCESS_KEY, ENTERPRISE_SALES_INBOX_EMAIL, ENTERPRISE_SPAM_API_KEY, + HCAPTCHA_SECRET_KEY, SALESFORCE_CLIENT_ID, SALESFORCE_CLIENT_SECRET, SALESFORCE_INSTANCE_URL, @@ -53,6 +57,7 @@ new sst.cloudflare.x.SolidStart("Console", { //VITE_DOCS_URL: web.url.apply((url) => url!), //VITE_API_URL: gateway.url.apply((url) => url!), VITE_CONSOLE_MARKETING_ENABLED: $app.stage === "dev" ? "true" : "false", + VITE_HCAPTCHA_SITE_KEY: HCAPTCHA_SITE_KEY.value, }, transform: { server: { diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index f7f0d7ab8adf..d0de9f576084 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -360,6 +360,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "تنسيق البريد الإلكتروني غير صالح.", "enterprise.form.error.invalidInferenceSpend": "اختر نطاقًا صالحًا للإنفاق على الاستدلال.", "enterprise.form.error.internalServer": "خطأ داخلي في الخادم.", + "enterprise.form.error.captcha": "فشل التحقق من captcha. يرجى المحاولة مرة أخرى.", "enterprise.faq.title": "الأسئلة الشائعة", "enterprise.faq.q1": "ما هو OpenCode Enterprise؟", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 3974d8b83c69..716a410c7d25 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -369,6 +369,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Formato de e-mail inválido.", "enterprise.form.error.invalidInferenceSpend": "Selecione uma faixa de gasto com inferência válida.", "enterprise.form.error.internalServer": "Erro interno do servidor.", + "enterprise.form.error.captcha": "A verificação captcha falhou. Tente novamente.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "O que é OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index 90b85a700391..33c6810057a5 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -366,6 +366,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Ugyldigt e-mailformat.", "enterprise.form.error.invalidInferenceSpend": "Vælg et gyldigt interval for inferensforbrug.", "enterprise.form.error.internalServer": "Intern serverfejl.", + "enterprise.form.error.captcha": "Captcha-verificering mislykkedes. Prøv igen.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "Hvad er OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 26b0d0066074..f80cb7db5789 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -370,6 +370,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Ungültiges E-Mail-Format.", "enterprise.form.error.invalidInferenceSpend": "Wählen Sie einen gültigen Bereich für die Inferenzausgaben.", "enterprise.form.error.internalServer": "Interner Serverfehler.", + "enterprise.form.error.captcha": "Captcha-Überprüfung fehlgeschlagen. Bitte versuchen Sie es erneut.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "Was ist OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index 0f28b12fde2d..4de55091bdd3 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -364,6 +364,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Invalid email format.", "enterprise.form.error.invalidInferenceSpend": "Select a valid inference spend range.", "enterprise.form.error.internalServer": "Internal server error.", + "enterprise.form.error.captcha": "Captcha verification failed. Please try again.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "What is OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 3748a8e64bec..4f6c68048f5a 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -370,6 +370,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Formato de correo inválido.", "enterprise.form.error.invalidInferenceSpend": "Selecciona un rango de gasto en inferencia válido.", "enterprise.form.error.internalServer": "Error interno del servidor.", + "enterprise.form.error.captcha": "La verificación captcha falló. Inténtalo de nuevo.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "¿Qué es OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 59ed7abaf376..884049335019 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -373,6 +373,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Format d'e-mail invalide.", "enterprise.form.error.invalidInferenceSpend": "Sélectionnez une tranche de dépenses d'inférence valide.", "enterprise.form.error.internalServer": "Erreur interne du serveur.", + "enterprise.form.error.captcha": "La vérification captcha a échoué. Veuillez réessayer.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "Qu'est-ce que OpenCode Enterprise ?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 5977aec9f786..cd80f5f334ac 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -366,6 +366,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Formato email non valido.", "enterprise.form.error.invalidInferenceSpend": "Seleziona una fascia di spesa per l'inferenza valida.", "enterprise.form.error.internalServer": "Errore interno del server.", + "enterprise.form.error.captcha": "Verifica captcha non riuscita. Riprova.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "Cos'è OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 39e211e54ead..eb901e3d6d05 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -364,6 +364,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "無効なメール形式です。", "enterprise.form.error.invalidInferenceSpend": "有効な推論費用の範囲を選択してください。", "enterprise.form.error.internalServer": "内部サーバーエラー。", + "enterprise.form.error.captcha": "CAPTCHA認証に失敗しました。もう一度お試しください。", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "OpenCode Enterpriseとは?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 6c67c2bea662..5acc290b3b30 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -357,6 +357,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "유효하지 않은 이메일 형식입니다.", "enterprise.form.error.invalidInferenceSpend": "유효한 추론 비용 범위를 선택하세요.", "enterprise.form.error.internalServer": "내부 서버 오류입니다.", + "enterprise.form.error.captcha": "캡차 인증에 실패했습니다. 다시 시도해 주세요.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "OpenCode 엔터프라이즈란 무엇인가요?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 87c96ac40f36..4510dddc9418 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -367,6 +367,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Ugyldig e-postformat.", "enterprise.form.error.invalidInferenceSpend": "Velg et gyldig intervall for inferensforbruk.", "enterprise.form.error.internalServer": "Intern serverfeil.", + "enterprise.form.error.captcha": "Captcha-verifiseringen mislyktes. Prøv igjen.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "Hva er OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index a651a54d9fee..29c6a1b5aaa0 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -368,6 +368,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Nieprawidłowy format adresu e-mail.", "enterprise.form.error.invalidInferenceSpend": "Wybierz prawidłowy przedział wydatków na inferencję.", "enterprise.form.error.internalServer": "Wewnętrzny błąd serwera.", + "enterprise.form.error.captcha": "Weryfikacja captcha nie powiodła się. Spróbuj ponownie.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "Czym jest OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 3bb97fe3843e..254e2c175ac7 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -371,6 +371,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Неверный формат email.", "enterprise.form.error.invalidInferenceSpend": "Выберите допустимый диапазон расходов на инференс.", "enterprise.form.error.internalServer": "Внутренняя ошибка сервера.", + "enterprise.form.error.captcha": "Не удалось пройти проверку captcha. Попробуйте ещё раз.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "Что такое OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 2979735d2530..11955c8e7f17 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -363,6 +363,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "รูปแบบอีเมลไม่ถูกต้อง", "enterprise.form.error.invalidInferenceSpend": "เลือกช่วงค่าใช้จ่ายด้าน inference ที่ถูกต้อง", "enterprise.form.error.internalServer": "เกิดข้อผิดพลาดภายในเซิร์ฟเวอร์", + "enterprise.form.error.captcha": "การยืนยัน captcha ล้มเหลว โปรดลองอีกครั้ง", "enterprise.faq.title": "คำถามที่พบบ่อย", "enterprise.faq.q1": "OpenCode Enterprise คืออะไร?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 3ad1406e298e..eb50cced01eb 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -370,6 +370,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Geçersiz e-posta formatı.", "enterprise.form.error.invalidInferenceSpend": "Geçerli bir çıkarım harcaması aralığı seçin.", "enterprise.form.error.internalServer": "İç sunucu hatası.", + "enterprise.form.error.captcha": "Captcha doğrulaması başarısız oldu. Lütfen tekrar deneyin.", "enterprise.faq.title": "SSS", "enterprise.faq.q1": "OpenCode Enterprise nedir?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/uk.ts b/packages/console/app/src/i18n/uk.ts index 18f0eb016eef..98b36ac18dc2 100644 --- a/packages/console/app/src/i18n/uk.ts +++ b/packages/console/app/src/i18n/uk.ts @@ -366,6 +366,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Недійсний формат email.", "enterprise.form.error.invalidInferenceSpend": "Виберіть допустимий діапазон витрат на інференс.", "enterprise.form.error.internalServer": "Внутрішня помилка сервера.", + "enterprise.form.error.captcha": "Не вдалося пройти перевірку captcha. Спробуйте ще раз.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "Що таке OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index 35e27cc78ccc..91d1b11376aa 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -348,6 +348,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "邮箱格式无效。", "enterprise.form.error.invalidInferenceSpend": "请选择有效的推理支出范围。", "enterprise.form.error.internalServer": "内部服务器错误。", + "enterprise.form.error.captcha": "验证码验证失败,请重试。", "enterprise.faq.title": "常见问题", "enterprise.faq.q1": "什么是 OpenCode 企业版?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index d15cbab40f8c..8c57bed0dd4f 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -346,6 +346,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "無效的電子郵件格式。", "enterprise.form.error.invalidInferenceSpend": "請選擇有效的推論支出範圍。", "enterprise.form.error.internalServer": "內部伺服器錯誤。", + "enterprise.form.error.captcha": "驗證碼驗證失敗,請再試一次。", "enterprise.faq.title": "常見問題", "enterprise.faq.q1": "什麼是 OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/lib/enterprise-spam.test.ts b/packages/console/app/src/lib/enterprise-spam.test.ts index 75785b0da9bd..0806beb630e9 100644 --- a/packages/console/app/src/lib/enterprise-spam.test.ts +++ b/packages/console/app/src/lib/enterprise-spam.test.ts @@ -71,10 +71,11 @@ const submit = async (fetch: (request: Request) => Promise) => { new Request("http://localhost/api/enterprise", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...submission, alias: "" }), + body: JSON.stringify({ ...submission, alias: "", captcha: "test-token" }), }), { spam: { inferenceUrl: "https://opencode.test/inference", apiKey: "test-key", fetch }, + captcha: { secret: "test-secret", fetch: async () => Response.json({ success: true }) }, salesforceSubmit: async (payload) => { leads.push(payload) return true diff --git a/packages/console/app/src/lib/enterprise.ts b/packages/console/app/src/lib/enterprise.ts index ba26d5f6639d..1652813a35ec 100644 --- a/packages/console/app/src/lib/enterprise.ts +++ b/packages/console/app/src/lib/enterprise.ts @@ -6,6 +6,7 @@ import { createLead } from "~/lib/salesforce" import { inferenceSpendOptions } from "~/lib/inference-spend" import { lookupConsoleAccount, type ConsoleAccount } from "~/lib/console-account" import { ENTERPRISE_SPAM_THRESHOLD, enterpriseSpamProbability } from "~/lib/enterprise-spam" +import { verifyHcaptcha } from "~/lib/hcaptcha" interface EnterpriseFormData { name: string @@ -16,6 +17,7 @@ interface EnterpriseFormData { inferenceSpend?: string alias?: string message: string + captcha?: string } const EMAIL_OCTOPUS_LIST_ID = "1b381e5e-39bd-11f1-ba4a-cdd4791f0c43" @@ -98,6 +100,7 @@ export async function handleEnterpriseRequest( notifications?: boolean accountLookup?: (email: string) => Promise spam?: Parameters[1] + captcha?: Parameters[1] } = {}, ) { const dict = i18n(localeFromRequest(request)) @@ -123,6 +126,18 @@ export async function handleEnterpriseRequest( return Response.json({ error: dict["enterprise.form.error.invalidInferenceSpend"] }, { status: 400 }) } + if (typeof body.captcha !== "string" || !body.captcha) { + return Response.json({ error: dict["enterprise.form.error.captcha"] }, { status: 400 }) + } + // Fail open when hCaptcha itself is unavailable; only a rejected token blocks the lead. + const human = await verifyHcaptcha(body.captcha, { + remoteip: request.headers.get("cf-connecting-ip") ?? undefined, + ...options.captcha, + }) + if (human === false) { + return Response.json({ error: dict["enterprise.form.error.captcha"] }, { status: 400 }) + } + // Fail open: a missing score never blocks a lead. Confident spam gets the same // response as the honeypot and never reaches Salesforce, email, or EmailOctopus. const spam = await enterpriseSpamProbability(body, options.spam) diff --git a/packages/console/app/src/lib/hcaptcha.test.ts b/packages/console/app/src/lib/hcaptcha.test.ts new file mode 100644 index 000000000000..c11601efb8cd --- /dev/null +++ b/packages/console/app/src/lib/hcaptcha.test.ts @@ -0,0 +1,99 @@ +import { afterEach, expect, mock, spyOn, test } from "bun:test" +import { verifyHcaptcha } from "./hcaptcha" +import { handleEnterpriseRequest } from "./enterprise" + +afterEach(() => mock.restore()) + +test("verifies a token with hCaptcha siteverify", async () => { + const requests: Request[] = [] + const result = await verifyHcaptcha("token-123", { + secret: "test-secret", + remoteip: "203.0.113.7", + fetch: async (request) => { + requests.push(request) + return Response.json({ success: true }) + }, + }) + + expect(result).toBe(true) + expect(requests[0].url).toBe("https://api.hcaptcha.com/siteverify") + const body = new URLSearchParams(await requests[0].text()) + expect(body.get("secret")).toBe("test-secret") + expect(body.get("response")).toBe("token-123") + expect(body.get("remoteip")).toBe("203.0.113.7") +}) + +test("rejects tokens hCaptcha says are invalid", async () => { + spyOn(console, "warn").mockImplementation(() => {}) + const result = await verifyHcaptcha("bad", { + secret: "test-secret", + fetch: async () => Response.json({ success: false, "error-codes": ["invalid-input-response"] }), + }) + expect(result).toBe(false) +}) + +test("returns undefined when hCaptcha is unreachable or misconfigured", async () => { + spyOn(console, "error").mockImplementation(() => {}) + const failures = [ + async () => new Response("down", { status: 503 }), + async () => { + throw new Error("network") + }, + async () => Response.json({ success: false, "error-codes": ["invalid-input-secret"] }), + (request: Request) => + new Promise((_, reject) => + request.signal.addEventListener("abort", () => reject(request.signal.reason)), + ), + ] + for (const fetch of failures) { + expect(await verifyHcaptcha("token", { secret: "test-secret", timeoutMs: 20, fetch })).toBeUndefined() + } +}) + +const submit = async (captcha: string | undefined, verify: () => Promise) => { + const leads: Record[] = [] + const response = await handleEnterpriseRequest( + new Request("http://localhost/api/enterprise", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "Fake Visitor", + role: "Engineering lead", + email: "fake-visitor@example.test", + message: "We need SSO for 200 developers.", + captcha, + }), + }), + { + captcha: { secret: "test-secret", fetch: verify }, + spam: { inferenceUrl: "" }, + salesforceSubmit: async (payload) => { + leads.push(payload) + return true + }, + notifications: false, + accountLookup: async () => null, + }, + ) + return { status: response.status, leads } +} + +test("the enterprise form rejects missing or invalid captcha tokens before creating a lead", async () => { + spyOn(console, "warn").mockImplementation(() => {}) + const verified: string[] = [] + const missing = await submit(undefined, async () => { + verified.push("called") + return Response.json({ success: true }) + }) + expect(missing).toEqual({ status: 400, leads: [] }) + expect(verified).toHaveLength(0) + + const invalid = await submit("bad", async () => Response.json({ success: false })) + expect(invalid).toEqual({ status: 400, leads: [] }) +}) + +test("the enterprise form accepts verified tokens and fails open when hCaptcha is down", async () => { + spyOn(console, "error").mockImplementation(() => {}) + expect((await submit("good", async () => Response.json({ success: true }))).leads).toHaveLength(1) + expect((await submit("good", async () => new Response("down", { status: 503 }))).leads).toHaveLength(1) +}) diff --git a/packages/console/app/src/lib/hcaptcha.ts b/packages/console/app/src/lib/hcaptcha.ts new file mode 100644 index 000000000000..bc9a62cc044e --- /dev/null +++ b/packages/console/app/src/lib/hcaptcha.ts @@ -0,0 +1,44 @@ +import { Resource } from "@opencode/console-resource" + +const TIMEOUT_MS = 3000 +const CONFIG_ERRORS = new Set(["missing-input-secret", "invalid-input-secret", "sitekey-secret-mismatch"]) + +// Returns undefined when hCaptcha is unreachable so the caller can fail open. +export async function verifyHcaptcha( + token: string, + options: { + fetch?: (request: Request) => Promise + secret?: string + remoteip?: string + timeoutMs?: number + } = {}, +): Promise { + try { + const body = new URLSearchParams({ secret: options.secret ?? Resource.HCAPTCHA_SECRET_KEY.value, response: token }) + if (options.remoteip) body.set("remoteip", options.remoteip) + const response = await (options.fetch ?? fetch)( + new Request("https://api.hcaptcha.com/siteverify", { + method: "POST", + body, + signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS), + }), + ) + if (!response.ok) { + console.error("hCaptcha verification failed:", response.status) + return undefined + } + const data = (await response.json()) as { success?: unknown; "error-codes"?: unknown } + if (data.success === true) return true + const errors = Array.isArray(data["error-codes"]) ? data["error-codes"] : [] + // A broken secret or site key is our misconfiguration, not a bot, so it fails open too. + if (errors.some((code) => CONFIG_ERRORS.has(code))) { + console.error("hCaptcha is misconfigured:", errors) + return undefined + } + console.warn("hCaptcha rejected token", { errors }) + return false + } catch (error) { + console.error("hCaptcha verification unavailable:", error instanceof Error ? error.name : "unknown") + return undefined + } +} diff --git a/packages/console/app/src/lib/salesforce.test.ts b/packages/console/app/src/lib/salesforce.test.ts index 32c9f6274c60..36cf5419e84b 100644 --- a/packages/console/app/src/lib/salesforce.test.ts +++ b/packages/console/app/src/lib/salesforce.test.ts @@ -77,9 +77,11 @@ test("the local Enterprise request handler applies the updated mapping to a form inferenceSpend: "50k-100k", message: " How are you bro!\nThis is a local form test. ", alias: "", + captcha: "test-token", }), }), { + captcha: { secret: "test-secret", fetch: async () => Response.json({ success: true }) }, salesforceSubmit: async (payload) => { const response = await fetch(`${server.url.origin}/services/data/v59.0/sobjects/Lead`, { method: "POST", diff --git a/packages/console/app/src/routes/enterprise/index.tsx b/packages/console/app/src/routes/enterprise/index.tsx index 5def8f208bd0..588019cf76b6 100644 --- a/packages/console/app/src/routes/enterprise/index.tsx +++ b/packages/console/app/src/routes/enterprise/index.tsx @@ -1,6 +1,6 @@ import "./index.css" import { Title, Meta } from "@solidjs/meta" -import { createSignal, For, Show } from "solid-js" +import { createSignal, For, onMount, Show } from "solid-js" import { Header } from "~/component/header" import { Footer } from "~/component/footer" import { Legal } from "~/component/legal" @@ -10,8 +10,43 @@ import { LocaleLinks } from "~/component/locale-links" import { SocialMeta } from "~/component/social-meta" import { inferenceSpendOptions } from "~/lib/inference-spend" +type HCaptcha = { + render(container: HTMLElement, params: { sitekey: string; size: "invisible" }): string + execute(widget: string, options: { async: true }): Promise<{ response: string }> + reset(widget: string): void +} + +declare global { + interface Window { + hcaptcha?: HCaptcha + onEnterpriseHcaptchaLoad?: () => void + } +} + +function loadHcaptcha(container: HTMLElement) { + const sitekey = import.meta.env.VITE_HCAPTCHA_SITE_KEY + if (!sitekey) return Promise.reject(new Error("VITE_HCAPTCHA_SITE_KEY is not set")) + return new Promise<{ api: HCaptcha; widget: string }>((resolve, reject) => { + window.onEnterpriseHcaptchaLoad = () => { + const api = window.hcaptcha! + resolve({ api, widget: api.render(container, { sitekey, size: "invisible" }) }) + } + const script = document.createElement("script") + script.src = "https://js.hcaptcha.com/1/api.js?render=explicit&onload=onEnterpriseHcaptchaLoad" + script.async = true + script.onerror = () => reject(new Error("hCaptcha failed to load")) + document.head.appendChild(script) + }) +} + export default function Enterprise() { const i18n = useI18n() + let captchaContainer!: HTMLDivElement + let captcha: ReturnType | undefined + onMount(() => { + captcha = loadHcaptcha(captchaContainer) + captcha.catch((error) => console.error(error)) + }) const [formData, setFormData] = createSignal({ name: "", role: "", @@ -37,13 +72,28 @@ export default function Enterprise() { setShowSuccess(false) setIsSubmitting(true) + const token = await captcha + ?.then((hcaptcha) => + hcaptcha.api.execute(hcaptcha.widget, { async: true }).finally(() => hcaptcha.api.reset(hcaptcha.widget)), + ) + .then((result) => result.response) + .catch((error) => { + console.error("hCaptcha challenge failed:", error) + return undefined + }) + if (!token) { + setError(i18n.t("enterprise.form.error.captcha")) + setIsSubmitting(false) + return + } + try { const response = await fetch("/api/enterprise", { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify(formData()), + body: JSON.stringify({ ...formData(), captcha: token }), }) if (response.ok) { @@ -77,7 +127,11 @@ export default function Enterprise() { {i18n.t("enterprise.title")} - +
@@ -265,6 +319,8 @@ export default function Enterprise() { />
+
+