blob: 82998fb0ee1b4742258409e9e157830cc53c953e [file] [view]
# 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.