blob: 7f3ff8d547e38eb1fb5eca701d1a0669b815c899 [file] [view] [edit]
# 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.