| # 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` v`3.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 |
| |
| | 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. |
| |
| ## 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 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. | |
| |
| ## 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 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. | |
| |
| ## Privileged Actions |
| |
| | 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` | |
| |
| ## 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. |