| # Security Threat Model: Apache ECharts (AppSheet Fork) |
| |
| ## Asset Definition & Scope |
| |
| - **Component:** Apache ECharts — client-side JavaScript/TypeScript charting |
| and data-visualization library (AppSheet fork) |
| - **Repository:** Git-on-Borg |
| (`https://gnocchi-internal.googlesource.com/third_party/echarts`, branch |
| `main`). Mirrored as `appsheet-third-party/echarts`, which is the URL |
| actually resolved by `Nirvana/Content/package.json`. |
| - **Upstream version:** Apache ECharts `5.4.0` (upstream commit |
| `cb722aecad5b44476a5c2f4c343b699cfa834b0e`, per `METADATA`; |
| `last_upgrade_date` 2022-11-16). Declared in `package.json` and |
| `src/core/echarts.ts:version`. |
| - **Runtime dependency:** `zrender` pinned to a pre-release nightly |
| (`npm:zrender-nightly@^5.4.1-dev.20221104`) rather than a stable release. |
| - **Scope:** Browser-side rendering library bundled into the AppSheet web |
| frontend (`jeenee/Nirvana/Content`). It renders bar, line, area, pie, |
| doughnut, scatter, KPI and graph charts for (a) end-user-facing AppSheet |
| application chart views and (b) the AppSheet Admin Console. Scope covers the |
| TypeScript sources under `src/` **and** the pre-built bundles checked into |
| `dist/`, which are shipped to browsers. |
| - **Fork delta:** The fork consists of a squashed upstream import plus four |
| commits (`4e53257`, `1bd413c`, `60d53ba`, `967afa1`), all of which modify |
| `src/component/tooltip/TooltipHTMLContent.ts` (and regenerated `dist/` |
| bundles) to suppress **Trusted Types** enforcement violations. No upstream |
| security patches have been backported. |
| |
| ## Prioritization Signals |
| |
| - **1P OSS:** No (Third-party Apache-licensed OSS maintained as an internal |
| customized fork for Google AppSheet) |
| - **1P Proprietary Shipped Software:** Yes (Compiled and minified into the |
| AppSheet frontend JavaScript bundles served to every AppSheet user's |
| browser; `dist/*.min.js` artifacts are committed to this repository) |
| - **High-Risk Code Surface:** Yes (Client-side rendering engine that |
| interpolates caller-supplied strings into `innerHTML`, contains an |
| intentionally **pass-through Trusted Types policy** added by the fork, |
| performs dynamic code evaluation via `new Function` in GeoJSON parsing, and |
| writes unvalidated CSS into inline `style` attributes) |
| - **Perimeter Exposure:** Yes (Executes in the browsers of public Internet |
| users of AppSheet applications and in the AppSheet Admin Console; AppSheet |
| is a 1P service exposed to the public Internet and to customers) |
| - **Data Sensitivity:** High (Renders customer business records — the row |
| values, column display names, app names, and creator email addresses that |
| flow into chart series, axis categories, labels, and tooltips are customer |
| business data and PII, displayed client-side) |
| - **Untrusted Input Handling:** Yes (Two distinct untrusted sources: **chart |
| configuration authored by AppSheet app creators** — chart type, column |
| display names, aggregations, titles — and **record data entered by |
| application end users**, both of which reach label and tooltip rendering |
| paths) |
| - **Business Value:** ECharts is the sole charting engine behind AppSheet |
| chart views and the Admin Console dashboards. A DOM-XSS defect in a tooltip |
| or label sink executes attacker JavaScript in the AppSheet origin, enabling |
| session theft, cross-tenant data access, and privilege escalation against |
| app creators and Admin Console operators. The fork's deliberate weakening of |
| Trusted Types removes a defense-in-depth control that would otherwise |
| mitigate exactly this class of bug. |
| |
| ## Scanning Harness Prompts |
| |
| 1. **Pass-through Trusted Types policies (fork-introduced, highest priority).** |
| `TooltipHTMLContent.setContent` creates Trusted Types policies named |
| `TooltipHTMLFix1`, `TooltipHTMLFix2`, and `TooltipHTMLFix3`. `TooltipHTMLFix2` |
| is defined as `createHTML: (string) => string` — an identity function that |
| launders arbitrary strings into `TrustedHTML` and assigns them to |
| `el.innerHTML`. Treat this as a **Trusted Types bypass**, not a fix: it |
| converts a browser-enforced XSS guardrail into a no-op while satisfying the |
| violation reporter. Flag every string reaching this sink. Also flag that |
| `createPolicy` is invoked on **every** `setContent` call (i.e. on every |
| tooltip render) rather than once at module init — under a CSP |
| `trusted-types` allow-list without `allow-duplicates` this throws a |
| `TypeError` on the second call, and it is an unbounded policy-creation hot |
| path. |
| 2. **Tooltip formatter → `innerHTML` data flow.** Trace |
| `TooltipView.prototype._showTooltipContent` → `tooltipContent.setContent` → |
| `TooltipHTMLContent.setContent` → `el.innerHTML`. Note the asymmetry: a |
| **string** `formatter` has its `{a}/{b}/{c}` placeholders escaped via |
| `formatTpl(html, params, true)` → `encodeHTML`, but the template shell |
| itself is raw HTML; a **function** `formatter` return value is inserted with |
| **no escaping whatsoever**. The default (no-formatter) markup path in |
| `src/component/tooltip/tooltipMarkup.ts` does apply `encodeHTML` to names |
| and values and is the safe baseline — verify no regression there. |
| 3. **CSS injection via `extraCssText`.** In `TooltipHTMLContent.prototype.show`, |
| `tooltipModel.get('extraCssText')` is concatenated raw into |
| `el.style.cssText` with no validation or delimiter escaping. Check for |
| injection of additional CSS declarations, and for exfiltration primitives |
| such as `background:url(...)` attribute selectors. |
| 4. **Dynamic code evaluation in GeoJSON parsing.** |
| `src/coord/geo/GeoJSONResource.ts:parseInput` falls back to |
| `new Function('return (' + source + ');')()` when `JSON.parse` is |
| unavailable. Confirm this fallback is unreachable in supported browsers and |
| that `registerMap` callers never pass attacker-controlled strings; any reach |
| is direct arbitrary JavaScript execution. |
| 5. **Unprotected `innerHTML` / `document.write` sinks outside the tooltip.** |
| The fork patched *only* the tooltip. Audit the following, none of which are |
| wrapped in a Trusted Types policy and all of which will hard-fail or bypass |
| under TT enforcement: |
| - `src/component/toolbox/feature/DataView.ts` — `header.innerHTML` from |
| `lang[0] || model.get('title')`, `viewMain.innerHTML = htmlOrDom` from |
| the caller-supplied `optionToContent` callback, and |
| `closeButton.innerHTML` / `refreshButton.innerHTML` from `lang`. |
| - `src/component/toolbox/feature/SaveAsImage.ts` — |
| `tab.document.write(html)` and `doc.write(bstr)` build markup by string |
| concatenation, interpolating `url` and `lang[0]` into an `<img |
| src=... title=...>` attribute context. |
| - `src/core/echarts.ts` — `getSvgRoot().innerHTML = content` in the |
| connected-charts SVG export path. |
| - `TooltipHTMLContent.setContent` — `arrowEl.innerHTML = arrow` in the |
| `HTMLElement[]` content branch is **not** routed through any policy, |
| unlike the sibling string branch. |
| 6. **Build-artifact divergence between `src/` and `dist/` (supply-chain |
| integrity).** The committed bundles are inconsistent with each other and |
| with the sources — see the table under *Privileged Actions*. |
| `dist/echarts.simple.js` / `.simple.min.js` contain **none** of the fork |
| patches, and `dist/echarts.esm.js` / `.esm.min.js` are stale at the |
| `4e53257` state (only the empty-clear `TooltipHTMLFix1` policy; missing the |
| `TooltipHTMLFix2` string-content path). Verify which artifact AppSheet |
| actually ships and whether `dist/` can be reproduced from `src/`. |
| 7. **Rich-text template token injection.** Label `formatter` values that return |
| ECharts rich-text markup (`{styleName|text}`) interpolate data values into |
| the template. Unescaped `{`, `}`, or `|` characters in a data value can |
| break out of the intended token and select a different `rich` style. This is |
| canvas/SVG-rendered rather than an HTML sink, so treat it as a UI-spoofing |
| and rendering-integrity issue rather than XSS. |
| 8. **Missing upstream security patches.** The fork has not been rebased since |
| 2022-11-16. Enumerate Apache ECharts and `zrender` advisories affecting |
| `5.4.0` and the `zrender-nightly@5.4.1-dev.20221104` pre-release, and check |
| whether each fix is absent here. |
| |
| ## Entry Points and Untrusted Inputs |
| |
| All entry points are library APIs invoked by AppSheet frontend code; the library |
| performs no authentication of its own. |
| |
| | Entry Point | Type | Trusted? | Validation | |
| |---|---|---|---| |
| | `echartsInstance.setOption(option)` — the primary API; receives the full chart config | Public API / config object | No | No schema validation or sanitization; option values are mapped onto component models and rendered | |
| | `tooltip.formatter` (function) — e.g. `ChartView.tsx`, `bar_chart_builder.ts`, `scatter_chart_builder.ts` | Callback returning HTML | No | **None.** Return value flows to `el.innerHTML` via the pass-through TT policy | |
| | `tooltip.formatter` (string template) | HTML template with `{a}/{b}/{c}` | No | Placeholders escaped by `formatTpl(..., encode=true)` → `encodeHTML`; the surrounding template is raw HTML | |
| | Default tooltip markup (no `formatter`) | Internal markup builder | No | `tooltipMarkup.ts` applies `encodeHTML` to series names and values — safe baseline | |
| | `tooltip.extraCssText` | Raw CSS string | No | **None.** Concatenated directly into `el.style.cssText` | |
| | `series.label.formatter` / `axisLabel.formatter` / legend formatter | String or callback | No | Rendered as canvas/SVG text, not HTML; rich-text `{style|text}` tokens are parsed without escaping data values | |
| | `series[].name`, `series.data[].name`, `xAxis.data[]`, `title.text` | Config strings sourced from customer data | No | Escaped in the default tooltip path; passed verbatim into custom formatters | |
| | `graphic` component elements | zrender shape descriptors | No | Rendered via zrender (canvas/SVG), not HTML. `type: 'image'` accepts an arbitrary `style.image` URL → outbound request from the AppSheet origin | |
| | `echarts.registerMap(name, geoJson)` | GeoJSON object or string | No | `GeoJSONResource.parseInput` uses `JSON.parse`, falling back to `new Function(...)` | |
| | `toolbox.dataView.optionToContent` | Callback returning HTML/DOM | No | **None.** String return assigned to `viewMain.innerHTML` | |
| | `echarts.registerTheme(name, theme)` — used by `ChartView.tsx` (`darkTheme()`) | Theme object | Yes | First-party static theme definition | |
| |
| ## Trust Boundaries and Auth Assumptions |
| |
| - **Authentication**: None at the library level. ECharts runs entirely in the |
| browser and authenticates nothing. All authentication is performed upstream |
| by AppSheet before chart data is delivered to the client. |
| - **Authorization**: None at the library level. Row-level and table-level |
| access control is enforced by the AppSheet backend and the AppSheet security |
| filter model before rows reach `queryRowValues` and are passed into chart |
| options. |
| - **Implicit trust**: The library implicitly treats **every string in the |
| option object as trusted, renderable markup**. This assumption is invalid in |
| AppSheet, where option strings are assembled from two untrusted sources: |
| app-creator-authored view configuration (column display names, titles, |
| aggregations) and end-user-entered record values. |
| - **Trusted Types**: The AppSheet frontend runs under a Trusted Types policy. |
| The fork's patches satisfy that policy **without** providing sanitization; |
| the browser guardrail that would otherwise block tooltip DOM-XSS is |
| intentionally neutralized for this code path. Any XSS analysis must assume |
| Trusted Types provides **no** protection for ECharts tooltips. |
| - **Same-origin blast radius**: Charts render in the main AppSheet document, |
| not a sandboxed iframe. Script execution in a chart tooltip runs with full |
| AppSheet origin privileges (session cookies, `localStorage`, in-page APIs). |
| - **Boundary crossings**: App-creator view definition **and** end-user record |
| data → AppSheet backend → AppSheet frontend (`ChartView.tsx`, chart option |
| builders) → ECharts option object → tooltip/label rendering → browser DOM. |
| The critical crossing is the last one, where data leaves the React-escaped |
| world and enters raw HTML string construction. |
| |
| ## Sensitive Data Paths |
| |
| | Data Type | Source | Destination | Protection | |
| |---|---|---|---| |
| | Customer record values (row/cell data) | AppSheet table query (`table.queryRowValues`) | Chart series data, axis categories, pie slice names, tooltip HTML | Access-controlled server-side; **unescaped** once inside custom `formatter` callbacks | |
| | Column display names / chart titles | App creator's saved view definition (`ChartConfig`) | Axis names, series names, `title.text`, tooltip label prefixes | Creator-authored; treated as trusted markup by the library | |
| | Customer app names and creator email addresses (PII) | AppSheet Admin Console APIs | Admin Console tooltip HTML via the shared `tooltipFormatter` | Interpolated into an HTML string with no escaping | |
| | Customer-supplied app logo URLs | App metadata | `rich[...].backgroundColor.image` on axis labels | Loaded as an image resource from the AppSheet origin | |
| | Chart bitmap / SVG export | Rendered canvas or SVG root | `toDataURL`, download anchor, or a new browser tab | Contains whatever customer data is on-screen; written via `document.write` in the fallback path | |
| |
| ## Privileged Actions |
| |
| | Action | Location | Guard | |
| |---|---|---| |
| | HTML injection into the live DOM | `TooltipHTMLContent.setContent` → `el.innerHTML` | Pass-through Trusted Types policy `TooltipHTMLFix2` (`createHTML: s => s`) — **no effective guard** | |
| | Trusted Types policy creation (per render) | `TooltipHTMLContent.setContent` (`TooltipHTMLFix1/2/3`) | None; requires the policy names to be CSP-allow-listed, and duplicates to be permitted | |
| | Inline CSS injection | `TooltipHTMLContent.show` → `el.style.cssText` (`extraCssText`) | None | |
| | Unpoliced HTML injection | `TooltipHTMLContent.setContent` → `arrowEl.innerHTML` (DOM-content branch) | None | |
| | Dynamic code evaluation | `GeoJSONResource.ts:parseInput` → `new Function(...)` | Reachable only when `JSON.parse` is unavailable | |
| | HTML injection (toolbox) | `DataView.ts` → `header.innerHTML`, `viewMain.innerHTML`, `closeButton.innerHTML`, `refreshButton.innerHTML` | None; feature is opt-in via `toolbox.feature.dataView` | |
| | New-window document write | `SaveAsImage.ts` → `tab.document.write(html)`, `doc.write(bstr)` | None; markup built by string concatenation | |
| | SVG DOM replacement | `echarts.ts` → `(zr.painter as SVGPainter).getSvgRoot().innerHTML = content` | None; connected-charts export path | |
| | Outbound network request | `graphic` `type: 'image'` / `rich[...].backgroundColor.image` | None; arbitrary URL fetched from the AppSheet origin | |
| | Shipping pre-built browser bundles | `dist/echarts*.js`, `dist/echarts*.min.js` | Committed artifacts; not verifiably reproducible from `src/` (see below) | |
| |
| **Fork-patch coverage across committed bundles** — the patches applied to `src/` |
| are not uniformly present in `dist/`: |
| |
| | Bundle | `TooltipHTMLFix1` | `TooltipHTMLFix2` | `TooltipHTMLFix3` | State | |
| |---|---|---|---|---| |
| | `echarts.js` / `echarts.min.js` | yes | yes | yes | Matches `src/` (HEAD) | |
| | `echarts.common.js` / `.common.min.js` | yes | yes | yes | Matches `src/` (HEAD) | |
| | `echarts.esm.js` / `.esm.min.js` | yes (×2) | **no** | **no** | Stale at commit `4e53257` | |
| | `echarts.simple.js` / `.simple.min.js` | **no** | **no** | **no** | Unpatched upstream code | |
| |
| `package.json` sets `main: dist/echarts.js` (patched) and `module: index.js` |
| (compiled from patched `src/`), so the default resolution paths are current. |
| AppSheet imports the library as `import('echarts')` (full bundle, not |
| tree-shaken), which resolves to `main`/`module`. The `esm` and `simple` bundles |
| remain divergent and should be regenerated or removed. |
| |
| ## Consumer Context (AppSheet) |
| |
| Recorded here because it determines exploitability of the sinks above. |
| |
| - **Dependency pin:** `Nirvana/Content/package.json` declares |
| `"echarts": "git+https://appsheet-third-party.googlesource.com/echarts"` with |
| **no `#ref` and no semver constraint** — it tracks the fork's default |
| branch. `package-lock.json` resolves it to |
| `#967afa1ae54628568880206a569b42d40189f566` with **no `integrity` hash** |
| (npm omits subresource integrity for git dependencies). A push to the fork's |
| `main` therefore lands in AppSheet on the next lockfile refresh, with no |
| hash pinning. |
| - **Primary consumer:** `Nirvana/Content/components/app/primary/ChartView.tsx` |
| renders end-user-facing chart views. Chart configuration is read from the |
| app creator's saved view definition (`ChartConfig`); row data is pulled via |
| `table.queryRowValues` and passed into the option builders under |
| `src/editor/charts/helpers/create_chart_option/`. |
| - **Function formatters returning HTML built from record data:** |
| `ChartView.tsx` (scatter tooltip, joins `value[i]` with `<br/>`), |
| `bar_chart_builder.ts` (`getBarLineAreaTooltips`), and |
| `scatter_chart_builder.ts` all concatenate row values and creator-authored |
| column display names into HTML strings with no escaping. |
| - **Admin Console:** `components/admin/console/overview/echarts_options.ts` |
| exports a shared `tooltipFormatter` that interpolates `data.name` and |
| `data.value` into a `<p>`/`<span>`/`<b>` HTML string. Its callers pass |
| customer-authored app names (`PopularAppsCard.tsx`) and creator email |
| addresses (`MostAppsOwnedCard.tsx`). |
| - **Rich-text templates fed by data:** `pie_chart_builder.ts` and |
| `kpi_helper_functions.ts` build `{bold|...}` / `{header|...}` rich-text |
| tokens from row values and series names. |
| - **Configuration not used:** No `renderMode`, `appendToBody`, `appendTo`, or |
| `graphic` configuration appears anywhere in the AppSheet frontend, so |
| tooltips use the default `renderMode: 'html'` — i.e. the `innerHTML` path |
| above — and the tooltip DOM stays within the chart container. |
| - **No sanitization wrapper:** No DOMPurify or equivalent escaping is applied |
| to any ECharts formatter output in the AppSheet frontend. |
| |
| ## Priority Review Areas |
| |
| 1. **Pass-through Trusted Types policy in `TooltipHTMLContent.setContent`** — |
| the fork's `createHTML: (string) => string` identity policy launders |
| arbitrary strings into `TrustedHTML`, silencing the violation while removing |
| the protection; this is the single highest-value finding in the repository |
| and should be replaced with real sanitization. |
| 2. **Function-`formatter` → `innerHTML` DOM-XSS chain** — AppSheet's tooltip |
| formatters concatenate end-user record values and creator-authored column |
| names into HTML with no escaping, and the library performs none on the |
| callback return path, giving a direct customer-data-to-script-execution |
| route in the AppSheet origin. |
| 3. **`extraCssText` raw CSS injection** — `tooltipModel.get('extraCssText')` is |
| concatenated straight into `el.style.cssText`, permitting arbitrary |
| declarations and CSS-based data exfiltration if it is ever fed |
| configuration-derived text. |
| 4. **Unpatched HTML sinks outside the tooltip** — `DataView.ts`, |
| `SaveAsImage.ts`, the connected-charts SVG export in `echarts.ts`, and the |
| `arrowEl.innerHTML` branch received no fork hardening and will either bypass |
| or break under Trusted Types enforcement. |
| 5. **`new Function` evaluation in `GeoJSONResource.parseInput`** — a dynamic |
| code-execution primitive reachable from `registerMap` input whenever |
| `JSON.parse` is unavailable; confirm unreachability or remove the fallback. |
| 6. **`dist/` bundle divergence and unpinned dependency resolution** — the |
| committed `esm` and `simple` bundles do not correspond to `src/`, the |
| bundles are unverifiable build artifacts shipped to browsers, and AppSheet |
| consumes the fork from a branch reference with no integrity hash, so this is |
| a supply-chain integrity gap in addition to a patch-coverage gap. |
| 7. **Stale upstream baseline** — pinned to ECharts `5.4.0` (November 2022) and |
| a `zrender` nightly pre-release, with no rebase since; any upstream ECharts |
| or zrender security fix released after that date is presumed missing. |
| 8. **Rich-text token injection in canvas labels** — unescaped `{`, `}`, and `|` |
| in row values can break out of `{style|text}` templates in the pie and KPI |
| label formatters, causing label spoofing and rendering-integrity issues |
| (not XSS, as these are canvas-rendered). |
| |
| ## Out of Scope |
| |
| Teams can fill out this section with any known filed vulnerability bugs that |
| they've determined should be out of scope from future Fortify bug filing. |