Add Fortify scanner THREAT_MODEL.md to third_party/jquery-modal repo

Bug: b/548454898
Change-Id: I3f25f5865853a2d26158c45bf1ed9471523e783a
Reviewed-on: https://gnocchi-internal-review.git.corp.google.com/c/third_party/jquery-modal/+/316586
Reviewed-by: Trevor Ryland <tryland@google.com>
diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
new file mode 100644
index 0000000..82998fb
--- /dev/null
+++ b/THREAT_MODEL.md
@@ -0,0 +1,255 @@
+# Security Threat Model: jquery-modal (AppSheet Fork)
+
+## Asset Definition & Scope
+
+-   **Component:** jquery-modal — a lightweight jQuery modal / lightbox plugin
+    (upstream `kylefox/jquery-modal` v0.9.1; this fork self-identifies as
+    `Version 0.9.1-patched` in the `jquery.modal.js` banner).
+-   **Repository:** Git-on-Borg
+    (`https://gnocchi-internal.googlesource.com/third_party/jquery-modal`,
+    branch `main`).
+-   **Scope:** The entire repository, but in practice the security-relevant
+    surface is two files: the plugin source `jquery.modal.js` and the build
+    artefact `jquery.modal.min.js` it produces via `gulpfile.js` (uglify).
+    `jquery.modal.css` / `.min.css`, `examples/`, `compositor.json`,
+    `close.sketch`, and `CHANGELOG.md` carry no executable server or client
+    logic of interest.
+
+> [!IMPORTANT]
+> **This fork reaches production as a *vendored copy*, not as an npm/bower
+> dependency.** No `package.json` in AppSheet's `jeenee` monorepo declares a
+> dependency on `jquery-modal` — dependency-graph tooling will therefore report
+> this repository as unused. It is not. The build artefact in this repository is
+> **byte-identical** (verified by MD5, `8e82b0b05201141e034c93709b61f88a`) to
+> the file checked into the AppSheet web frontend at
+> `jeenee/Nirvana/Content/scripts/_shared/external/jquery-modal-0.9.1.min.js`.
+> That file is served to end users. This repository is the confirmed build
+> source of live, Internet-facing production JavaScript, and findings here are
+> directly exploitable findings in AppSheet.
+
+**Production consumer.** The vendored copy is loaded by exactly one view, the
+AppSheet **sign-in / sign-up page**
+(`jeenee/V3/MainServer/Views/Account/Login.cshtml`):
+
+-   `Login.cshtml:45` — `<link rel="stylesheet" href="~/Content/css/jquery-modal-0.9.1.min.css">`
+-   `Login.cshtml:116` — `<script nonce="@CspNonce" src="~/Content/scripts/_shared/external/jquery-modal-0.9.1.min.js">`
+
+Its purpose is to display an individual AppSheet application's **Terms of
+Service** and **Privacy Policy** inline in a modal rather than navigating away
+from the login flow. `Login.cshtml:37-38` construct the two links as
+`/appinfo/termsofuse?appId=<AppId>` and `/appinfo/privacypolicy?appId=<AppId>`;
+`Login.cshtml:39` sets `relMode = "modal:open"` whenever `ViewBag.AppId != null`
+and the client is not iOS (iOS is excluded per b/463902264); `Login.cshtml:109-110`
+render the anchors with `rel="@relMode"`.
+
+Because those `href` values are **not** fragment identifiers (`#…`), the plugin
+takes its **AJAX branch** (`jquery.modal.js`, `$.modal` constructor, the `else`
+arm of the `/^#/.test(target)` check). The AJAX branch is therefore *exercised
+in production*, on an unauthenticated page — it is not dead code.
+
+## Prioritization Signals
+
+-   **1P OSS:** No. Third-party MIT-licensed OSS maintained as an internal
+    Google fork for AppSheet.
+-   **1P Proprietary Shipped Software:** Yes. `jquery.modal.min.js` is minified
+    JavaScript shipped verbatim to every visitor of the AppSheet login page.
+-   **High-Risk Code Surface:** Yes. The fork contains a **hand-written Trusted
+    Types policy that performs no sanitisation**, wrapping a raw `innerHTML`
+    assignment of a network-fetched HTML response. It also performs unvalidated
+    URL dereferencing driven by DOM attributes.
+-   **Perimeter Exposure:** **Yes — high.** The sole consumer is the public,
+    unauthenticated AppSheet sign-in/sign-up page, reachable by anonymous users
+    on the open Internet, before any authentication decision has been made.
+-   **Data Sensitivity:** High by context rather than by content. The library
+    itself handles no PII, but it executes in the DOM of the page that collects
+    credentials and federated-identity flows. A DOM compromise here is a
+    credential-phishing / session-theft primitive, not merely a defacement.
+-   **Untrusted Input Handling:** Yes. It consumes (a) `href` attribute values
+    from arbitrary anchors bearing `rel~="modal:open"`, and (b) the full HTTP
+    response body returned from dereferencing those URLs, which it injects into
+    the document as HTML without sanitisation.
+-   **Business Value:** Small library, disproportionate risk. It is one of the
+    only pieces of third-party JavaScript on AppSheet's pre-authentication
+    credential-entry page, and its Google-authored patch deliberately weakens the
+    application's Trusted Types posture.
+
+## Scanning Harness Prompts
+
+1.  **Trusted Types policy bypass (highest priority).** In `jquery.modal.js`,
+    inside the `$.get(target).done(...)` callback of the `$.modal` constructor,
+    the fork creates a Trusted Types policy named **`jquery-modal-fix`** whose
+    `createHTML` callback **ignores its argument and returns the closed-over
+    `html` response body verbatim**:
+    `createHTML: function(_ignored) { return html }`, invoked as
+    `policy.createHTML('_ignored')`. The result is assigned to
+    `current.$elm[0].innerHTML`. Flag this as a sanitiser-bypass / TT escape
+    hatch: it satisfies the Trusted Types type check while providing **zero**
+    filtering, so any HTML in the fetched response is injected into the DOM.
+    Confirm the identical construct in the minified artefact
+    `jquery.modal.min.js` (search for the string `jquery-modal-fix`).
+2.  **Unvalidated URL dereference / SSRF-into-DOM.** Still in the `$.modal`
+    constructor, `target = el.attr('href')` is taken directly from the clicked
+    anchor and passed to `$.get(target)`. The only branch condition is
+    `/^#/.test(target)`; there is **no scheme allowlist, no same-origin check,
+    no path allowlist**. Any anchor anywhere on a page that loads this plugin and
+    carries `rel~="modal:open"` (bound globally at the bottom of the file via
+    `$(document).on('click.modal', 'a[rel~="modal:open"]', ...)`) will cause the
+    referenced URL to be fetched and its body injected. Trace whether an
+    attacker can influence any `href` or inject an anchor with that `rel`.
+3.  **`trustedTypes` referenced unguarded.** The line
+    `current.$elm[0].innerHTML = trustedTypes.emptyHTML;` dereferences the bare
+    global `trustedTypes` **outside** the `if (window.trustedTypes && …)` guard
+    that immediately precedes it. In a browser without Trusted Types this throws
+    a `ReferenceError` inside the jQuery Deferred callback. Assess this as a
+    correctness/availability defect and check whether the resulting partially
+    initialised modal state (`modals` array already pushed, spinner still
+    mounted) is exploitable or merely a broken UI.
+4.  **Implicit globals.** `safeHtml` (AJAX callback) and `txt` (`show()`) are
+    assigned without `var`/`let`, creating window-scoped globals. Check for
+    cross-modal state leakage and clobbering of same-named globals on the host
+    page.
+5.  **Dead close-button assignment.** In `show()`, the element is built into the
+    local `closeButton`, but the append uses `this.$elm.append(this.closeButton)`
+    — `this.closeButton` is never assigned, so the close control is never added
+    and `hide()`'s `if (this.closeButton) this.closeButton.remove()` is dead.
+    Verify whether losing the close affordance forces users into the
+    escape/overlay-click paths and whether any modal is rendered unclosable.
+6.  **Regression check against the TT rewrite.** Commits `89f9a94`, `df04d26`
+    and `07569a3` rewrote `showSpinner()`, `show()` and `block()` to use
+    `document.createElement` / `setAttribute` / `createTextNode` in place of
+    jQuery HTML-string construction. Verify no residual HTML-string sink remains
+    (the `spinnerHtml` entry in `$.modal.defaults` is now vestigial and unused —
+    confirm it is genuinely unreferenced).
+
+## Entry Points and Untrusted Inputs
+
+| Entry Point | Type | Trusted? | Validation |
+|---|---|---|---|
+| `a[rel~="modal:open"]` global click delegate (bottom of `jquery.modal.js`) | DOM event | No — matches *any* anchor in the document | None. Any matching anchor triggers `$(this).modal()` |
+| `$.modal` constructor, `target = el.attr('href')` | DOM attribute | No | Only `/^#/` is special-cased (same-document element). Everything else is fetched |
+| `$.get(target)` response body (`html`) | HTTP response | No | **None.** Passed through the no-op `jquery-modal-fix` TT policy and assigned to `innerHTML` |
+| `$.modal.defaults` overrides via `$(el).modal(options)` | JS API | Partially — caller-supplied | `closeText`, `closeClass`, `modalClass`, `blockerClass` flow into `createTextNode` / `setAttribute`, so they are text/attribute-safe, not HTML-safe |
+| `keydown` (Esc) and blocker click handlers | DOM event | N/A | Only invoke `close()` |
+
+## Trust Boundaries and Auth Assumptions
+
+-   **Authentication:** None at the library layer. Critically, the *hosting
+    page* is also unauthenticated — `Login.cshtml` is the pre-auth
+    sign-in/sign-up surface, so the plugin runs in an anonymous-origin context.
+-   **Authorization:** None. The plugin fetches whatever URL an anchor names,
+    using the browser's ambient credentials for that origin.
+-   **Implicit trust:** The fork implicitly assumes that (a) every `href` on a
+    `rel="modal:open"` anchor is a trustworthy, first-party URL, and (b) the
+    HTML body returned from that URL is safe to inject. Neither is enforced in
+    code. In the current production wiring, the URL is server-constructed
+    (`/appinfo/{termsofuse,privacypolicy}?appId=<AppId>`) and same-origin, so the
+    assumption *happens* to hold today — but nothing in the library preserves it,
+    and the injected content is app-owner-authored Terms/Privacy text rendered by
+    an AppSheet endpoint, i.e. content originating from a third party (the app
+    creator) rather than from Google.
+-   **Boundary crossings:** Anonymous Internet visitor → AppSheet login page →
+    plugin click handler → same-origin `GET /appinfo/*` → **response body
+    injected as live HTML into the login page DOM**. The last hop is the trust
+    boundary that this fork removes the guard from.
+
+### Mitigating control: CSP
+
+`Login.cshtml` emits every `<script>` tag with `nonce="@CspNonce"`, which
+indicates a **nonce-based Content Security Policy** is enforced on this page. A
+correctly configured nonce CSP would block `<script>` elements injected via
+`innerHTML` (injected nodes carry no valid nonce) and, in most modern browsers,
+`innerHTML` does not execute inline `<script>` at all.
+
+This threat model therefore does **not** claim unmitigated script execution.
+What the `jquery-modal-fix` policy demonstrably does is:
+
+-   **launder arbitrary HTML past Trusted Types**, defeating the exact control
+    the 2023 patch series was created to satisfy — the policy is a
+    rubber stamp, and its presence means TT provides no assurance on this path;
+-   permit injection of non-`<script>` content that CSP does not necessarily
+    cover: attacker-controlled markup, styling, overlaid forms, and links on the
+    credential-entry page — i.e. **in-page phishing / UI redress** against
+    users mid-login;
+-   leave the door open to full script execution the moment the CSP is
+    misconfigured, relaxed, contains an `unsafe-inline` fallback, or the page is
+    rendered in a context where the nonce is absent.
+
+**A reviewer should independently verify the effective CSP response headers on
+the `/Account/Login` route** — the nonce attribute in the view proves a nonce is
+minted, not that `script-src` lacks `unsafe-inline` or a permissive fallback.
+
+## Sensitive Data Paths
+
+| Data Type | Source | Destination | Protection |
+|---|---|---|---|
+| Application ID (`appId`) | `ViewBag.AppId` → anchor `href` (`Login.cshtml:37-38`) | `$.get(target)` query string | Server-constructed; not user-supplied at the client layer |
+| App Terms of Service / Privacy Policy HTML | `GET /appinfo/{termsofuse,privacypolicy}` — content authored by the **app creator**, a third party relative to Google | `current.$elm[0].innerHTML` on the login page | **None.** No sanitisation; the TT policy is a pass-through |
+| Login page DOM (credential fields, federated-identity buttons, CSRF tokens) | AppSheet server-rendered | Shares a document with the injected HTML | Same-origin isolation only; no iframe/sandbox boundary between injected content and the credential form |
+
+## Privileged Actions
+
+| Action | Location | Guard |
+|---|---|---|
+| Create a Trusted Types policy that returns unsanitised HTML | `jquery.modal.js` : `$.modal` constructor, `$.get().done()` callback — policy name `jquery-modal-fix` | **None.** `createHTML` discards its input and returns the fetched body verbatim |
+| Assign untrusted HTML to `innerHTML` | `jquery.modal.js` : `$.modal` constructor, `$.get().done()` callback (`current.$elm[0].innerHTML = safeHtml`) | Trusted Types type check only — satisfied by the no-op policy above |
+| Outbound HTTP `GET` to a DOM-supplied URL | `jquery.modal.js` : `$.modal` constructor (`$.get(target)`) | Only `/^#/` diverts to the local-element path; no scheme or origin allowlist |
+| DOM mutation: append blocker/spinner/modal to `<body>` | `$.modal.prototype.block`, `.showSpinner`, `.show` | Element-API construction (`createElement`/`setAttribute`/`createTextNode`) — no HTML strings |
+| Global document event binding (`click.modal`, `keydown.modal`) | Module IIFE tail; `$.modal.prototype.open` | Delegated on `document`; applies to every anchor on any page loading the plugin |
+
+## Priority Review Areas
+
+1.  **The `jquery-modal-fix` Trusted Types escape hatch (`$.get().done()` in
+    `jquery.modal.js`).** A Trusted Types policy whose `createHTML` ignores its
+    argument and returns a network-fetched response body verbatim is a
+    sanitiser bypass by construction. It converts the fetched HTML into a
+    `TrustedHTML` that passes the browser's TT enforcement while providing no
+    filtering whatsoever, and the result is written straight to `innerHTML` on
+    the **public, unauthenticated login page**. This nullifies Trusted Types on
+    the one path that actually needed it and should be replaced with a real
+    sanitiser (e.g. DOMPurify with `RETURN_TRUSTED_TYPE: true`) or with
+    element-API construction. **Highest priority.**
+2.  **Absence of a scheme/origin allowlist on the `href` → `$.get` flow
+    (`$.modal` constructor).** The `/^#/` test is the sole branch discriminator.
+    Combined with the global `a[rel~="modal:open"]` delegate, any injected or
+    attacker-influenced anchor becomes a fetch-and-inject primitive. Review for
+    an allowlist of same-origin, path-prefixed URLs (e.g. `/appinfo/`).
+3.  **Effective CSP on `/Account/Login`, and the blast radius if it is weaker
+    than assumed.** Verify the deployed `script-src` directive. Even with a
+    strict nonce CSP, enumerate the non-script injection consequences on a
+    credential-entry page: overlaid fake login forms, `<a>`/`<form>` action
+    rewriting, and CSS-based UI redress.
+4.  **Provenance and trust level of `/appinfo/termsofuse` and
+    `/appinfo/privacypolicy` output.** These endpoints render app-creator-authored
+    content. Determine server-side whether that content is sanitised before it
+    leaves the server; if it is not, the missing client-side sanitisation in this
+    fork is the *only* thing standing between an app creator and injected markup
+    on AppSheet's login page for every user of their app.
+5.  **Unguarded `trustedTypes.emptyHTML` dereference and implicit globals
+    (`safeHtml`, `txt`).** Correctness and state-consistency defects introduced
+    by the patch series; assess for exploitable partial-initialisation states and
+    global clobbering.
+6.  **Source-to-artefact parity.** Confirm `jquery.modal.min.js` is a faithful
+    minification of `jquery.modal.js` at every commit. The minified file is
+    hand-committed (`8fe155c "Creating the patched minified file"`), not produced
+    by CI, so the shipped artefact could silently diverge from the reviewed
+    source.
+
+## Out of Scope
+
+-   `examples/`, `compositor.json`, `close.sketch`, `CHANGELOG.md`, `README.md`,
+    `bower.json`, `LICENSE` — documentation, design assets, and packaging
+    metadata with no production code path.
+-   `gulpfile.js` and its `devDependencies` (`gulp` 3.x and transitive packages,
+    with a `graceful-fs ^4.2.11` override) — build-time only; this toolchain is
+    not run in CI for this repository and produces no served artefact
+    automatically.
+-   `jquery.modal.css` / `jquery.modal.min.css` — stylesheets with no scripting
+    surface.
+-   jQuery itself. The plugin depends on a global `jQuery`, but that dependency
+    is satisfied by AppSheet's own separately vendored jQuery build, which is
+    tracked as its own Fortify target
+    (`gnocchi-internal/third_party/jquery`, b/548455274).
+-   Server-side rendering and authorisation of the `/appinfo/*` endpoints, and
+    the CSP middleware that sets `@CspNonce`. Both live in the `jeenee`
+    repository and must be reviewed there; they are referenced here only as
+    context and as mitigating controls to be verified.