From f3953075186dec9b9cc3ba3297a4f02041e59119 Mon Sep 17 00:00:00 2001 From: rohitneharabrowserstack Date: Tue, 25 Aug 2026 18:44:00 +0530 Subject: [PATCH] fix(security): RQ-3893 stop reading and writing appSumoCodes from the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This component performed both halves of AppSumo redemption client-side: it read `appSumoCodes/{code}` to validate a typed code, then wrote `redeemed: true` in a batch after the callable returned. That is the only reason the Firestore rules granted every authenticated user read+update on the collection — which let any account enumerate licence codes and clear `redeemed` on a paying customer's. requestly/requestly-cloud#880 closes the collection and moves both operations to the server. Those rules cannot deploy until this ships: with the collection locked, the read fails so no code ever validates and the submit button never enables, and the redeem write fails silently (it was un-awaited with no catch), leaving codes reusable while the user sees a success toast. - verifyCode calls the new `subscription-validateAppSumoCodes` callable, which returns a verdict only for codes the caller supplied rather than exposing the collection to a wildcard read (which also grants `list`). - redeemSubmittedCodes is deleted; the callable now marks codes redeemed in the same transaction that grants the tier. - An unsuccessful response that is not `max_limit_reached` no longer falls into the success branch and reports "unlocked" for a redemption that never happened. The workspace picker already restricted itself to workspaces where the caller is an admin, so it needs no change to match the callable's new admin check. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/landing/Appsumo/Appsumo.tsx | 67 ++++++++++++------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/app/src/components/landing/Appsumo/Appsumo.tsx b/app/src/components/landing/Appsumo/Appsumo.tsx index 6bee9c47d5..d9ce6a24f9 100644 --- a/app/src/components/landing/Appsumo/Appsumo.tsx +++ b/app/src/components/landing/Appsumo/Appsumo.tsx @@ -10,8 +10,6 @@ import { FiXCircle } from "@react-icons/all-files/fi/FiXCircle"; import { useNavigate } from "react-router-dom"; import { redirectToRoot } from "utils/RedirectionUtils"; import AppSumoWorkspaceDropdown from "components/landing/Appsumo/AppSumoWorkspaceDropdown/AppSumoWorkspaceDropdown"; -import { doc, getDoc, getFirestore, writeBatch } from "firebase/firestore"; -import firebaseApp from "../../../firebase"; import { toast } from "utils/Toast"; import { isEmailValid } from "utils/FormattingHelper"; import { useDebounce } from "hooks/useDebounce"; @@ -49,8 +47,6 @@ const AppSumoModal: React.FC = () => { const [isUpdatingSubscription, setIsUpdatingSubscription] = useState(false); const [showMaxCodesExeceededError, setShowMaxCodesExeceededError] = useState(false); - const db = getFirestore(firebaseApp); - const addAppSumoCodeInput = () => { if (appsumoCodes.length >= 10) { setShowMaxCodesExeceededError(true); @@ -96,35 +92,47 @@ const AppSumoModal: React.FC = () => { return; } - const docRef = doc(db, "appSumoCodes", enteredCode); - const docSnap = await getDoc(docRef); + // RQ-3893: the server validates now. This used to read `appSumoCodes/{code}` + // straight from the browser, which forced the Firestore rules to let every + // authenticated user read the collection — and a wildcard read also grants + // `list`, so the entire licence table was enumerable. The callable returns a + // verdict only for the code the user typed. + const validateAppSumoCodes = httpsCallable< + { codes: string[] }, + { success: boolean; message?: string; checks?: { code: string; valid: boolean; reason?: string }[] } + >(getFunctions(), "subscription-validateAppSumoCodes"); - if (!docSnap.exists()) { - updateAppSumoCode(index, "error", "Invalid code"); - updateAppSumoCode(index, "verified", false); - return; - } + try { + const response = await validateAppSumoCodes({ codes: [enteredCode] }); + const verdict = response?.data?.checks?.find((check) => check.code === enteredCode); - if (docSnap.data()?.redeemed) { - updateAppSumoCode(index, "error", "Code already redeemed"); + if (!response?.data?.success || !verdict) { + updateAppSumoCode(index, "error", "Could not verify this code, please try again"); + updateAppSumoCode(index, "verified", false); + return; + } + + if (!verdict.valid) { + updateAppSumoCode( + index, + "error", + verdict.reason === "already_redeemed" ? "Code already redeemed" : "Invalid code" + ); + updateAppSumoCode(index, "verified", false); + return; + } + } catch { + updateAppSumoCode(index, "error", "Could not verify this code, please try again"); updateAppSumoCode(index, "verified", false); return; } + updateAppSumoCode(index, "error", ""); updateAppSumoCode(index, "verified", true); }, - [db, appsumoCodes] + [appsumoCodes] ); - const redeemSubmittedCodes = useCallback(async () => { - const batch = writeBatch(db); - appsumoCodes.forEach((code) => { - const docRef = doc(db, "appSumoCodes", code.code); - batch.update(docRef, { redeemed: true }); - }); - await batch.commit(); - }, [db, appsumoCodes]); - const createNewWorkspaceForAppSumo = useCallback(async () => { setIsLoading(true); @@ -181,8 +189,16 @@ const AppSumoModal: React.FC = () => { .then((response) => { if (!response?.data?.success && response?.data?.error === "max_limit_reached") { setShowMaxCodesExeceededError(true); + } else if (!response?.data?.success) { + // RQ-3893: previously any unsuccessful response that was not + // `max_limit_reached` fell into the success branch below and showed an + // "unlocked" toast for a redemption that never happened. + toast.error(response?.data?.message || "Could not redeem these codes, please try again", 10); } else { - redeemSubmittedCodes(); + // RQ-3893: the codes are marked redeemed by the callable, inside the same + // transaction that grants the tier. The browser used to do it afterwards + // in an un-awaited batch write, so a failure there was silent and left the + // codes reusable. trackAppsumoCodeRedeemed(appsumoCodes.length); toast.success( `Lifetime access to SessionBook Plus unlocked for ${ @@ -198,13 +214,14 @@ const AppSumoModal: React.FC = () => { }); } catch (error) { console.error("from appsumo", error); + setIsUpdatingSubscription(false); + toast.error("Could not redeem these codes, please try again", 10); } }, [ createNewWorkspaceForAppSumo, appsumoCodes, emailValidationError, isAllCodeCheckPassed, - redeemSubmittedCodes, workspaceToUpgrade?.id, navigate, ]);