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