Security Threat Model: react-google-recaptcha (AppSheet Fork)

Asset Definition & Scope

  • Component: react-google-recaptcha — a thin React component wrapper around the Google reCAPTCHA v2 browser API (grecaptcha).
  • Repository: Git-on-Borg (https://gnocchi-internal.googlesource.com/third_party/react-google-recaptcha, branch main). Mirrored at https://appsheet-third-party.googlesource.com/react-google-recaptcha, which is the URL AppSheet's package.json actually resolves.
  • Upstream: dozoisch/react-google-recaptcha v3.0.0-alpha.1 (upstream release dated 2020-11-23). METADATA records version: d2924d7ddf7030b199c15c371631fd3a0f974f8f, last_upgrade_date 2020-06-05; note the METADATA version predates the imported 3.0.0-alpha.1 tree and appears stale.
  • Scope: Three source files under src/ — index.js, recaptcha-wrapper.js, and recaptcha.js — plus the Babel build that emits lib/ (CJS) and lib/esm/. Runtime dependencies are prop-types and react-async-script (the latter performs the actual <script> element injection and is not forked here).
  • Deployment context: Bundled into the AppSheet Editor single-page app and served to browsers over the public Internet. In AppSheet it renders the anti-abuse challenge in the Share modal → “Add new subscribers” flow (Nirvana/Content/src/editor/share_modal/add_new_subscribers/add_new_subscribers_section.tsx), gating the POST /manage/SubscribeEmailsWithPermissions endpoint that invites users/domains to an app. It is not wired into the primary Google-account sign-in path; the AppSheet surface it protects is invitation /notification abuse (mass email sending) rather than credential auth.

AppSheet fork deltas vs. upstream

CommitChange
74270e0 “Initial import”Imports upstream 3.0.0-alpha.1 with a Google-authored Trusted Types patch already applied to getURL() in src/recaptcha-wrapper.js: when self.trustedTypes exists and the caller opts in via window.recaptchaOptions.trustedTypes, the reCAPTCHA API URL is minted through a TrustedTypePolicy named react-google-recaptcha and &trustedtypes=true is appended. README documents the new trustedTypes option.
bdef355 “Fixing b/273972842” (HEAD)Corrects the policy invocation from the non-existent policy.create('_ignored') to policy.createScriptURL('_ignored'). Before this fix the Trusted Types branch threw at load time, so the widget failed to render whenever a page enabled the opt-in.

No changes were made to src/recaptcha.js (widget lifecycle / token callbacks) or src/index.js. Consequently the fork's security-relevant delta is confined to how the third-party api.js script URL is produced, not to token handling.

Prioritization Signals

  • 1P OSS: No (third-party OSS library maintained as an internal customized fork for Google AppSheet).
  • 1P Proprietary Shipped Software: Yes (transpiled and bundled into the minified JavaScript that AppSheet ships to end-user browsers).
  • High-Risk Code Surface: Yes — the component sits on AppSheet's anti-abuse control path and is responsible for injecting a remote <script> (https://www.google.com/recaptcha/api.js) into the application origin and for propagating the resulting reCAPTCHA token to application code. It is a small surface, but a control-bypass or script-origin defect here is security-relevant out of proportion to the line count.
  • Perimeter Exposure: Yes (AppSheet is a 1P service exposed to the public Internet and to customers; this code executes in the browsers of unauthenticated and authenticated users alike).
  • Data Sensitivity: Medium — handles the reCAPTCHA response token (a short-lived, single-use anti-abuse bearer credential) and the public sitekey. It does not handle passwords, OAuth tokens, cookies, or the reCAPTCHA secret key. Adjacent form state in the AppSheet caller includes invitee email addresses (PII), though those never enter this library.
  • Untrusted Input Handling: Yes, but indirect. The library consumes (a) the reCAPTCHA response token produced by the remote grecaptcha iframe, (b) the global window.recaptchaOptions object, and (c) arbitrary caller-supplied props (sitekey, theme, hl, stoken, badge, plus ...childProps spread onto a <div>). All of these are attacker-influenceable only if the attacker already controls the page or the caller.
  • Business Value: This is the client half of AppSheet's abuse control for app sharing/invitations. A client-side bypass alone is not sufficient to break the control (see Trust Boundaries), but a defect that allows an attacker to substitute a script origin, exfiltrate tokens, or silently disable the widget would degrade a defense-in-depth control on a publicly reachable, email-sending endpoint.

Scanning Harness Prompts

Direct the agentic scanner at the following, in priority order. Note that this is a ~200-line wrapper — depth matters more than breadth, and findings that amount to “client-side CAPTCHA can be bypassed by not running the client” are expected and should be reported as informational, not as vulnerabilities.

  1. Trusted Types policy correctness in src/recaptcha-wrapper.js:getURL. This is the only Google-modified code. Verify that the createScriptURL callback ignores its argument and returns a constant, non-attacker-influenceable URL. Flag any change that interpolates caller- or DOM-derived data into the returned script URL. Also verify the policy name react-google-recaptcha cannot be squatted or re-registered in a way that would let a second createPolicy call (trustedTypes.createPolicy throws on duplicate names when the CSP trusted-types directive lists the name once) cause a denial of service or an unhandled exception on re-mount.
  2. Script-origin control in src/recaptcha-wrapper.js:getOptions. The hostname is chosen from window.recaptchaOptions.useRecaptchaNet, selecting between www.google.com and recaptcha.net. Confirm the hostname remains a fixed allowlist of two literals and can never become a caller-supplied string. Any refactor that makes the host dynamic is a script-injection primitive against the AppSheet origin.
  3. nonce propagation. getOptions().nonce is passed straight into the injected <script> element's attributes via react-async-script. Check for attribute injection and for accidental nonce disclosure/reuse if AppSheet ever adopts a nonce-based CSP. Note the nonce is read once at module evaluation time, not per-render — a stale nonce would silently break CSP enforcement rather than fail closed.
  4. Token lifecycle in src/recaptcha.js. Audit handleChange, handleExpired, handleErrored, execute, executeAsync, reset, and forceReset for token retention, logging, or leakage. In particular check that the token is never written to console, to localStorage/sessionStorage, or to a URL/query string, and that handleExpired reliably clears the caller‘s stored token (it does so only indirectly, by invoking handleChange(null) when no onExpired prop is supplied — if the caller does supply onExpired, onChange is never called and clearing is the caller’s responsibility).
  5. Prop spreading in ReCAPTCHA.render. Every prop not explicitly consumed is spread onto a raw <div {...childProps} />. Look for cases where a caller could pass dangerouslySetInnerHTML or event-handler props through this spread. React itself blocks most injection here, but the pattern warrants confirmation.
  6. DOM lifecycle in ReCAPTCHA.explicitRender. A detached document.createElement("div") is handed to grecaptcha.render() and then appended to this.captcha. Check for missing null-guards on this.captcha (set asynchronously via the handleRecaptchaRef callback ref), double-render on componentDidUpdate, and widget-ID reuse across unmount/remount, all of which could leave a stale or orphaned widget whose token no longer corresponds to the visible challenge.
  7. Supply chain. react-async-script@^1.2.0 performs the actual document.createElement('script') + appendChild. It is an unforked npm dependency and is where the injected URL is consumed; treat it as in-scope for dependency CVE review even though its source is not in this repo.
  8. Deprecated-version risk. The pinned upstream is a 3.0.0-alpha prerelease from 2020. Check CHANGELOG.md and upstream releases for security fixes landed after 3.0.0-alpha.1 that are missing here.

Entry Points and Untrusted Inputs

Entry PointTypeTrusted?Validation
<ReCAPTCHA sitekey={...} /> props (src/recaptcha.js)React props from calling application codePartially — caller is 1P AppSheet code, but props are not compile-time constants in generalPropTypes runtime shape checks only (sitekey required string; theme/type/size/badge constrained to enumerated literals). PropTypes are stripped in production builds and are not a security control.
window.recaptchaOptions (src/recaptcha-wrapper.js:getOptions)Mutable global object read at module-eval timeNo — any script running in the AppSheet origin can set it before this module loadsNone. Consumed for useRecaptchaNet (host selection), trustedTypes (policy opt-in), and nonce (script attribute). AppSheet does not set this global anywhere in Nirvana/Content, so all three branches are currently inert.
reCAPTCHA response token → handleChange(token) (src/recaptcha.js)Callback invoked by the remote grecaptcha script/iframeNo — value originates outside the app and is opaque to the clientNone at this layer. Passed verbatim to the caller's onChange and to any pending executeAsync promise. Meaningful validation is server-side only.
grecaptcha global / props.grecaptchaInjected third-party global objectNo — supplied by the dynamically loaded api.js, or overridden by the caller in the “manual script load” mode documented in README.mdGuarded only by truthiness checks (if (this.props.grecaptcha && this._widgetId !== undefined)). A page-level attacker who defines window.grecaptcha first can shim the entire widget.
Remote script https://www.google.com/recaptcha/api.jsNetwork fetch / dynamic <script> injectionTrusted origin (fixed literal), untrusted content-at-restOrigin is one of two hard-coded hostnames. Integrity relies on TLS; no SRI (impossible — the script is intentionally mutable). Optionally minted as a TrustedScriptURL.
asyncScriptOnLoad, onChange, onExpired, onErrored callbacksCaller-supplied functionsYes (1P AppSheet code)Invoked unconditionally when present.

Trust Boundaries and Auth Assumptions

  • Authentication: None is performed by this library. The reCAPTCHA token is a proof-of-humanity artifact, not an identity assertion. In the AppSheet share flow the user is already authenticated by AppSheet's own session; reCAPTCHA is layered on top as an abuse control.
  • Authorization: None at the library level. The AppSheet backend (SubscribeEmailsWithPermissionsHandler) performs the app-ownership / permission checks independently of the CAPTCHA result.
  • Implicit trust:
    • Trusts that https://www.google.com/recaptcha/api.js (or recaptcha.net) is reachable and genuine, i.e. trusts TLS and DNS.
    • Trusts that no other script in the AppSheet origin has poisoned window.recaptchaOptions, window.grecaptcha, or trustedTypes.createPolicy before this module evaluates. This library offers no defense against a same-origin XSS; an attacker with script execution in the AppSheet origin can read the token directly, forge onChange, or stub out the widget entirely.
    • Trusts the caller to forward the token to the server and to clear it on expiry.
  • Boundary crossings:
    1. Browser (AppSheet origin) → google.com — dynamic script load.
    2. reCAPTCHA cross-origin iframe → AppSheet page — the token crosses back via the grecaptcha callback.
    3. AppSheet SPA → AppSheet backend — the token is posted as recaptchaResponse to POST /manage/SubscribeEmailsWithPermissions.
    4. AppSheet backend → Google verification service — this is the only trust-establishing step.

The client-side control is advisory. Any attacker can skip the widget and POST an arbitrary recaptchaResponse directly. The control has value only because AppSheet verifies the token server-side: SubscribeEmailsWithPermissionsHandler rejects an empty request.RecaptchaResponse and calls VerifyRecaptchaAsync(...) before proceeding, and the adjacent ASDB path uses reCAPTCHA Enterprise CreateAssessment via Tables/Core/Deps/Impl/ReCaptchaClientImpl.IsUserHumanAsync, which checks TokenProperties.Valid, the expected action, and the site key. Findings that merely observe “the token can be bypassed in the browser” are not vulnerabilities in this library; findings that show the server accepts an unverified, replayed, or wrong-action token are.

Sensitive Data Paths

Data TypeSourceDestinationProtection
reCAPTCHA response tokengrecaptcha iframe callback → ReCAPTCHA.handleChangeCaller's onChange prop → AppSheet Redux store (recaptchaResponse) → POST /manage/SubscribeEmailsWithPermissionsShort-lived (≈2 min) and single-use; validated server-side against the site key and expected action. Never persisted client-side by this library. Exposed to any same-origin script.
reCAPTCHA sitekeyCaller prop (hard-coded public constant in the AppSheet caller)grecaptcha.render() optionsPublic by design; not a secret. Server-side assessment independently pins the expected site key.
CSP noncewindow.recaptchaOptions.nonce<script> element attribute (via react-async-script)Read once at module-eval time. Unused by AppSheet today.
stoken (secure token)Caller propgrecaptcha.render() optionsLegacy reCAPTCHA feature; not used by AppSheet.
Invitee email addresses (PII)AppSheet share modal formAppSheet backendOut of scope — handled entirely by the caller; never touches this library. Listed because it is the data the CAPTCHA protects.

Privileged Actions

ActionLocationGuard
Mint a TrustedScriptURL for a remote scriptsrc/recaptcha-wrapper.js:getURL (trustedTypes.createPolicy('react-google-recaptcha', {createScriptURL}))Callback ignores its input and returns a template literal over a two-value hostname allowlist; gated on window.recaptchaOptions.trustedTypes being truthy
Inject a cross-origin <script> into the AppSheet originreact-async-script's makeAsyncScriptLoader, invoked from src/recaptcha-wrapper.js (module default export)Hard-coded hostname; page CSP script-src; optional Trusted Types policy
Create and attach a DOM subtree for the widgetReCAPTCHA.explicitRender (document.createElement("div") → this.captcha.appendChild(wrapper))this._widgetId === undefined idempotence check; ref set by ReCAPTCHA.handleRecaptchaRef
Render the third-party widget and register callbacksReCAPTCHA.explicitRender → grecaptcha.render(wrapper, {...})Truthiness check on props.grecaptcha and props.grecaptcha.render
Release a token to application codeReCAPTCHA.handleChange → props.onChange(token) and executionResolve(token)None — unconditional forwarding
Invalidate / re-arm the challengeReCAPTCHA.reset, ReCAPTCHA.forceReset, ReCAPTCHA.handleExpiredprops.grecaptcha truthiness; forceReset resets the default widget rather than this._widgetId

Priority Review Areas

  1. getURL() and the Google-authored Trusted Types patch (src/recaptcha-wrapper.js). This is the only code AppSheet has modified relative to upstream, and it governs the URL of a script loaded into the AppSheet origin. It already had one functional defect (policy.create vs policy.createScriptURL, fixed in bdef355 per b/273972842) that would have thrown on every load once enabled — evidence the branch is under-exercised. Verify the callback remains constant-valued, that duplicate createPolicy registration on component remount cannot throw an unhandled error, and that the two-hostname allowlist is preserved. Additionally note that AppSheet never sets window.recaptchaOptions, so the Trusted Types branch is currently dead code in production; if AppSheet rolls out a require-trusted-types-for 'script' CSP without also setting recaptchaOptions.trustedTypes = true, script injection will fail closed and the CAPTCHA will silently stop rendering.

  2. Global-state trust assumptions (getOptions, props.grecaptcha). Both the script hostname and the CAPTCHA implementation itself are sourced from mutable page globals with no integrity checking. Review whether AppSheet should freeze window.recaptchaOptions or assert the expected hostname. This is the highest-leverage design weakness, though exploiting it presupposes same-origin script execution.

  3. Token handling and expiry semantics (src/recaptcha.js:handleChange, handleExpired, executeAsync). Confirm no logging or persistence of the token, and that executionResolve/executionReject promise state cannot be resolved twice or leak a token to a stale promise across a reset(). Note the asymmetry: supplying an onExpired prop suppresses the implicit onChange(null) clear, so a caller that provides onExpired but forgets to zero its stored token can present an expired token to the server. The AppSheet caller does handle this correctly (expiredRecaptchaCallback dispatches SetRecaptchaResponse with '').

  4. Widget lifecycle and single-use enforcement (explicitRender, reset, forceReset). _widgetId is instance state guarded only by undefined checks, and forceReset() calls grecaptcha.reset() with no widget ID, resetting whichever widget reCAPTCHA considers default. On a page with more than one <ReCAPTCHA> this resets the wrong widget. Assess whether a stale/detached widget can yield a token that the UI presents as fresh — client-side token reuse is otherwise prevented by server-side single-use validation, so this is a correctness-with-security-consequence issue rather than a direct bypass.

  5. Stale upstream baseline and unforked transitive dependency. The tree is an unmaintained 3.0.0-alpha.1 prerelease (2020) and delegates all script injection to react-async-script@1.2.0. Review both for known advisories and for divergence from current upstream, and evaluate whether AppSheet should migrate to reCAPTCHA Enterprise's own client rather than carry this fork.

  6. Duplicate/divergent vendored copy (tracking risk, not a code defect). An additional full copy of this library exists inside the AppSheet monorepo at jeenee/Nirvana/Content/third_party/react-google-recaptcha/. It is byte-identical to this repository's HEAD except that its package.json build script omits the leading rm -rf lib, and it carries no METADATA. Nothing in the jeenee build references it — Nirvana/Content/package.json pins git+https://appsheet-third-party.googlesource.com/react-google-recaptcha#bdef355c5c767972c7f00b34bd5583c79f2025d4, i.e. this fork at this commit. The vendored directory is a residual artifact of the original Nov–Dec 2022 patching effort and is a drift hazard: a future security patch applied to only one of the two copies would leave the other silently stale. It should be deleted, or explicitly enrolled in scanning.

Out of Scope

  • Server-side reCAPTCHA verification (SubscribeEmailsWithPermissionsHandler.VerifyRecaptchaAsync, Tables/Core/Deps/Impl/ReCaptchaClientImpl) lives in the jeenee repository and is covered by that target, not this one. It is described above only to establish that the client-side control is backed by a server-side check.
  • The Google reCAPTCHA service and the api.js script itself.
  • react-async-script, prop-types, and other npm dependencies resolved from the public registry (dependency-scanning scope, not source scope).
  • Teams can extend this section with any known filed vulnerability bugs that they have determined should be out of scope from future Fortify bug filing.