react-google-recaptcha — a thin React component wrapper around the Google reCAPTCHA v2 browser API (grecaptcha).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.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.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).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.| Commit | Change |
|---|---|
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.
<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.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.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.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.
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.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.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.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).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.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.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.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 Point | Type | Trusted? | Validation |
|---|---|---|---|
<ReCAPTCHA sitekey={...} /> props (src/recaptcha.js) | React props from calling application code | Partially — caller is 1P AppSheet code, but props are not compile-time constants in general | PropTypes 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 time | No — any script running in the AppSheet origin can set it before this module loads | None. 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/iframe | No — value originates outside the app and is opaque to the client | None 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.grecaptcha | Injected third-party global object | No — supplied by the dynamically loaded api.js, or overridden by the caller in the “manual script load” mode documented in README.md | Guarded 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.js | Network fetch / dynamic <script> injection | Trusted origin (fixed literal), untrusted content-at-rest | Origin 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 callbacks | Caller-supplied functions | Yes (1P AppSheet code) | Invoked unconditionally when present. |
SubscribeEmailsWithPermissionsHandler) performs the app-ownership / permission checks independently of the CAPTCHA result.https://www.google.com/recaptcha/api.js (or recaptcha.net) is reachable and genuine, i.e. trusts TLS and DNS.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.google.com — dynamic script load.grecaptcha callback.recaptchaResponse to POST /manage/SubscribeEmailsWithPermissions.The client-side control is advisory. Any attacker can skip the widget and
POSTan arbitraryrecaptchaResponsedirectly. The control has value only because AppSheet verifies the token server-side:SubscribeEmailsWithPermissionsHandlerrejects an emptyrequest.RecaptchaResponseand callsVerifyRecaptchaAsync(...)before proceeding, and the adjacent ASDB path uses reCAPTCHA EnterpriseCreateAssessmentviaTables/Core/Deps/Impl/ReCaptchaClientImpl.IsUserHumanAsync, which checksTokenProperties.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.
| Data Type | Source | Destination | Protection |
|---|---|---|---|
| reCAPTCHA response token | grecaptcha iframe callback → ReCAPTCHA.handleChange | Caller's onChange prop → AppSheet Redux store (recaptchaResponse) → POST /manage/SubscribeEmailsWithPermissions | Short-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 sitekey | Caller prop (hard-coded public constant in the AppSheet caller) | grecaptcha.render() options | Public by design; not a secret. Server-side assessment independently pins the expected site key. |
CSP nonce | window.recaptchaOptions.nonce | <script> element attribute (via react-async-script) | Read once at module-eval time. Unused by AppSheet today. |
stoken (secure token) | Caller prop | grecaptcha.render() options | Legacy reCAPTCHA feature; not used by AppSheet. |
| Invitee email addresses (PII) | AppSheet share modal form | AppSheet backend | Out of scope — handled entirely by the caller; never touches this library. Listed because it is the data the CAPTCHA protects. |
| Action | Location | Guard |
|---|---|---|
Mint a TrustedScriptURL for a remote script | src/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 origin | react-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 widget | ReCAPTCHA.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 callbacks | ReCAPTCHA.explicitRender → grecaptcha.render(wrapper, {...}) | Truthiness check on props.grecaptcha and props.grecaptcha.render |
| Release a token to application code | ReCAPTCHA.handleChange → props.onChange(token) and executionResolve(token) | None — unconditional forwarding |
| Invalidate / re-arm the challenge | ReCAPTCHA.reset, ReCAPTCHA.forceReset, ReCAPTCHA.handleExpired | props.grecaptcha truthiness; forceReset resets the default widget rather than this._widgetId |
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.
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.
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 '').
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.
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.
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.
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.api.js script itself.react-async-script, prop-types, and other npm dependencies resolved from the public registry (dependency-scanning scope, not source scope).