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.