| # 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. |