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 PointTypeTrusted?Validation
a[rel~="modal:open"] global click delegate (bottom of jquery.modal.js)DOM eventNo — matches any anchor in the documentNone. Any matching anchor triggers $(this).modal()
$.modal constructor, target = el.attr('href')DOM attributeNoOnly /^#/ is special-cased (same-document element). Everything else is fetched
$.get(target) response body (html)HTTP responseNoNone. Passed through the no-op jquery-modal-fix TT policy and assigned to innerHTML
$.modal.defaults overrides via $(el).modal(options)JS APIPartially — caller-suppliedcloseText, closeClass, modalClass, blockerClass flow into createTextNode / setAttribute, so they are text/attribute-safe, not HTML-safe
keydown (Esc) and blocker click handlersDOM eventN/AOnly 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 TypeSourceDestinationProtection
Application ID (appId)ViewBag.AppId → anchor href (Login.cshtml:37-38)$.get(target) query stringServer-constructed; not user-supplied at the client layer
App Terms of Service / Privacy Policy HTMLGET /appinfo/{termsofuse,privacypolicy} — content authored by the app creator, a third party relative to Googlecurrent.$elm[0].innerHTML on the login pageNone. No sanitisation; the TT policy is a pass-through
Login page DOM (credential fields, federated-identity buttons, CSRF tokens)AppSheet server-renderedShares a document with the injected HTMLSame-origin isolation only; no iframe/sandbox boundary between injected content and the credential form

Privileged Actions

ActionLocationGuard
Create a Trusted Types policy that returns unsanitised HTMLjquery.modal.js : $.modal constructor, $.get().done() callback — policy name jquery-modal-fixNone. createHTML discards its input and returns the fetched body verbatim
Assign untrusted HTML to innerHTMLjquery.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 URLjquery.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, .showElement-API construction (createElement/setAttribute/createTextNode) — no HTML strings
Global document event binding (click.modal, keydown.modal)Module IIFE tail; $.modal.prototype.openDelegated 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.