Add Fortify scanner THREAT_MODEL.md to third_party/react-google-recaptcha repo Bug: b/548453446 Change-Id: I4e49d9b05b9b010e8bb0fd6f287eec5701d98c6b Reviewed-on: https://gnocchi-internal-review.git.corp.google.com/c/third_party/react-google-recaptcha/+/316651 Reviewed-by: Adam Stone <stoneadam@google.com> Autosubmit: Hughes Hilton <hugheshilton@google.com>
diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md new file mode 100644 index 0000000..7f3ff8d --- /dev/null +++ b/THREAT_MODEL.md
@@ -0,0 +1,285 @@ +# 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.