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.