Security Threat Model: d3 (AppSheet Fork)

Asset Definition & Scope

  • Component: D3.js — a JavaScript library for manipulating documents based on data. Upstream is d3/d3; per METADATA the fork's base is upstream v3.4.2, released February 2014. D3 v3 is end-of-life; upstream is now at v7 and no security fixes are backported to the v3 line.
  • Repository: Git-on-Borg (https://gnocchi-internal.googlesource.com/third_party/d3, branch main).
  • History: Two commits — 6776099 “Initial pristine commit of AppSheet OSS D3 dependency” (parentless import of upstream tag v3.4.2, 4637ee5a49ec9f2edea217869a0b4f43b3617912) and b081036 “Remove the jsdom dependency and code we do not use”.
  • Licence: BSD-3-Clause (METADATA license_type: NOTICE).
  • Scope: d3.min.js (146.8 KB — the artefact that ships), d3.js (326.2 KB, the readable build), and src/ (270 authored source files from which both are generated).

[!IMPORTANT] This fork is the confirmed build source of live, Internet-facing production JavaScript. d3.min.js in this repository is byte-identical (MD5 33a4640a0fc7e2a0ab47ec00b506f4c7, verified) to the file checked into the AppSheet web frontend at jeenee/Nirvana/Scripts/app/plugins/d3.min.js, which is served to authenticated AppSheet users. Findings here are directly exploitable findings in AppSheet.

Version identity is intact — unlike the sibling nvd3 fork. package.json keeps "version": "3.4.2", and bin/start stamps that value into src/start.js as var d3 = {version: "3.4.2"}, so the runtime global reports d3.version === "3.4.2". Version-keyed SCA and advisory matching will work against this asset. (The nvd3 fork deliberately detaches its version string; that caveat does not apply here.)

Migration in flight. AppSheet is moving from the vendored copy to consuming this repository directly as an npm dependency (git+https://appsheet-third-party.googlesource.com/d3#<sha>), loaded through a webpack shim (scripts/_shared/d3Init.ts) that exposes window.d3. Before and after that migration this repository is the source of truth for the shipped bytes; only the delivery path changes.

Consumption in AppSheet

d3.min.js is loaded by four live legacy Razor views, all with a CSP nonce:

ViewLineSurface
V3/MainServer/Views/template/commoneditor/appStatsPartial.cshtml101App usage statistics
V3/MainServer/Views/template/_showTablePartial.cshtml92App editor — table/column detail
V3/MainServer/Views/Shared/_perfDetailsPartial.cshtml57Performance / expression trace detail
V3/MainServer/Views/Shared/_logDetailsPartial.cshtml77Audit-log detail

These are the same four views that load nvd3. D3 is present primarily as nvd3's prerequisite: nvd3 requires a global d3 (v3) and its threat model explicitly scopes D3 out — “D3.js itself. The library requires a global d3 (v3), supplied separately by AppSheet; it is not vendored in this repository.” This document closes that gap. The two assets must be read together.

Direct D3 API use by AppSheet code is small and well-bounded:

CallLocation
d3.select('#' + $graph.attr('id'))Nirvana/Scripts/sp-theme/manageGraphs.js:117, :343
d3.select($selector[0])_showTablePartial.cshtml:235, _perfDetailsPartial.cshtml:387
d3.select('#LogVirtualColumnPerformanceGraph .graphDisplay')_perfDisplayPartial.cshtml:172
d3.format(',.0f')manageGraphs.js:139, :304
d3.time.days(...) / d3.time.scalemanageGraphs.js:182, :324

Nothing in AppSheet calls d3.csv, d3.tsv, d3.json, d3.html, d3.xhr, d3.text, d3.xml, or selection.html() directly. The highest-severity primitives catalogued below are therefore shipped and exported but not presently exercised by AppSheet's own code — latent rather than live. They remain one line of caller code away, and .html() is reached indirectly through nvd3 (see Prompt 2).

The AppSheet Customization

Commit b081036. Deletions and metadata only — no behavioural patches.

  • Deleted index.js, the jsdom-based server-side shim. Because upstream declared jsdom as a production dependency, npm promoted its entire subtree into AppSheet's production closure: 540 packages with it, 498 without — 42 packages attributable solely to the d3 → jsdom edge, along with the five manual licence-review issues they generated. Removing it is the stated reason the fork exists, per METADATA.
  • Deleted test/ (209 files, the only other jsdom consumer), bin/bower, bin/component, bower.json, component.json, composer.json, .travis.yml, CONTRIBUTING.md.
  • package.json: "main" index.js → d3.js; removed dependencies.jsdom; removed vows / seedrandom devDependencies and scripts.test; replaced the legacy "licenses": [{"type": "BSD", …}] array with the SPDX "license": "BSD-3-Clause".
  • Makefile: dropped bower.json / component.json from GENERATED_FILES, dropped the test: target and the now-dead %.json: bin/% pattern rule.

[!NOTE] d3.js and d3.min.js were not touched. Their MD5s are unchanged from upstream v3.4.2. This is the single most useful security property of this fork: the shipped artefact is stock upstream, so published upstream analysis of D3 3.4.2 applies verbatim, and any diff against upstream is a finding.

Prioritization Signals

  • 1P OSS: No. Third-party BSD-3-Clause OSS maintained as an internal Google fork for AppSheet.
  • 1P Proprietary Shipped Software: Yes. d3.min.js is minified JavaScript shipped verbatim to AppSheet editor and admin users.
  • High-Risk Code Surface: Yes. The bundle contains a new Function code-generation sink, an innerHTML sink, a createContextualFragment sink, and a general-purpose XHR primitive with no origin controls.
  • Perimeter Exposure: Yes. The consuming views are authenticated AppSheet editor / admin pages served on the public Internet.
  • Data Sensitivity: Medium–High. The hosting pages render customer app schemas, view names, spreadsheet cell data, end-user identifiers and activity timelines, and run with the operator's authenticated session.
  • Untrusted Input Handling: Yes, but indirect. D3 is a DOM toolkit, not a data renderer; in AppSheet the untrusted strings reach the DOM through nvd3's use of d3.selection.html(), not through AppSheet's own D3 calls.
  • Business Value: Substrate for AppSheet‘s usage-statistics, audit-log and performance-diagnostics surfaces — the surfaces where one tenant’s data is displayed to another principal.

Cross-tenant framing

Identical to nvd3‘s, because the hosting pages are identical: a malicious table name, column name or spreadsheet value primarily renders in the creator’s own editor (self-XSS, low severity alone), but the same data is rendered for team/organisation admins viewing apps they did not author and for AppSheet support and operations staff investigating tickets. The realistic threat is stored XSS delivered from a low-privilege app creator to a higher-privilege admin or support operator.

Scanning Harness Prompts

  1. new Function code generation in src/dsv/dsv.js:35-37 (highest priority). dsv.parse compiles a row-accessor function at runtime:
    var a = new Function("d", "return {" + row.map(function(name, i) {
      return JSON.stringify(name) + ": d[" + i + "]";
    }).join(",") + "}");
    
    The generated function body embeds the CSV/TSV header row, i.e. attacker influenceable text, and JSON.stringify(name) is the only thing separating that text from generated source. Flag on two independent grounds:
    • CSP / Trusted Types incompatibility. new Function requires unsafe-eval. This is the same class of defect AppSheet already patched out of jquery-ajax-unobtrusive. Under an enforcing policy this path fails closed (DSV parsing throws) rather than executing.
    • Code generation from untrusted data. Assess the quoting. JSON.stringify escapes quotes and backslashes, but does not escape U+2028 / U+2029; before ES2019 those were line terminators inside JS string literals and would break out of the generated literal. Modern engines (ES2019+) permit them in string literals, so there is no breakout on a current browser matrix — confirm the supported matrix rather than assuming it.
    • Exactly one new Function occurrence exists in the shipped d3.js (verified by count). No AppSheet code calls d3.csv / d3.tsv / dsv.parse.
  2. selection.html() → innerHTML (src/selection/html.js:6-9). Three innerHTML writes (function-valued, null-clearing and constant), plus one read in the getter branch. No escaping or sanitisation — by design, it is the raw-HTML API. This is the sink that actually matters in AppSheet, because nvd3 reaches it: nvd3‘s nv.models.tooltip default contentGenerator makes three .html() calls on untrusted series keys and values, and that path is not covered by nvd3’s DOMPurify patch. Cross-reference Priority Review Area 1 of the nvd3 threat model; the sink lives here, the unsanitised caller lives there.
  3. d3.html() — fetch and parse HTML (src/xhr/html.js).
    function d3_html(request) {
      var range = d3_document.createRange();
      range.selectNode(d3_document.body);
      return range.createContextualFragment(request.responseText);
    }
    
    createContextualFragment is a named Trusted Types sink and will construct live <script> elements from the response. Combined with prompt 4's absent origin controls this is a fetch-and-inject primitive — the D3 analogue of nv.utils.pjax, which the nvd3 model recommends deleting outright.
  4. d3.xhr — unrestricted outbound request (src/xhr/xhr.js). request.open(method, url, true) at :94 with no scheme or origin allowlist, caller-controlled setRequestHeader at :96, caller-controlled responseType at :98, and an XDomainRequest fallback at :24-26 for legacy IE. Runs with the page's ambient session credentials. Underlies d3.json, d3.text, d3.xml, d3.html and the DSV loaders.
  5. ReDoS review. Four modules construct regular expressions: src/locale/time-format.js, src/dsv/dsv.js, src/selection/on.js, src/selection/classed.js. time-format.js is the one that parses caller-supplied format and locale strings.
  6. Selector injection at the AppSheet call site. d3.select('#' + $graph.attr('id')) (manageGraphs.js:117, :343) builds a CSS selector by string concatenation from a live DOM attribute. Determine whether the graph element id can be influenced by app or table names.
  7. Build-artefact parity. d3.js and d3.min.js are hand-committed, not produced by CI. The build chain (Makefile → smash ~0.0.12 + uglify-js 2.4.0) is declared but those devDependencies are not installed or lockfile-pinned, so the build is not readily reproducible. Current best evidence of integrity is MD5 parity with the upstream v3.4.2 release — re-verify that at every commit, because the minified artefact is what ships.
  8. Stale, unmaintained base. v3.4.2 dates from February 2014. The v3 line receives no upstream security maintenance. Enumerate advisories against d3@3.4.2 and treat “no fix available upstream” as the expected result.

Verified non-findings (do not spend review time here)

  • Prototype pollution via d3.map / d3.set: mitigated. src/arrays/map.js prefixes every key with "\0" (var d3_map_prefix = "\0", // prevent collision with built-ins) in get, set, has, remove and forEach, so __proto__, constructor and friends cannot reach the prototype chain.
  • d3-color ReDoS (CVE-2020-7746 class): not applicable. That advisory targets the standalone d3-color package of the v4+ modular era. In 3.4.2 colour parsing lives in src/color/*.js and is a character-scan parser — no regular expressions at all in that directory (verified).
  • jsdom / server-side DOM: removed. Deleted in b081036; there is no server-side execution path in this fork.
  • lib/ never ships. lib/ contains vendored third-party material (colorbrewer/colorbrewer.js, science/science*.js, plus LICENSE-only directories for geographiclib, penner, polymaps, protovis, rhill-voronoi). It is excluded by .npmignore and verified absent from npm pack output, so it reaches neither node_modules nor the browser. It is not concatenated into d3.js by the Makefile.

Entry Points and Untrusted Inputs

Entry PointTypeTrusted?Validation
selection.html(value)JS API → innerHTMLNo — reached by nvd3 with customer-authored series keys and valuesNone by design. Raw-HTML API
DSV header row → dsv.parseText → generated JS sourceNo — arbitrary CSV/TSV header textJSON.stringify quoting only; not a security boundary. No AppSheet caller
DSV body rowsText → JS objectsNoCharacter-scan tokeniser; values become plain object properties
d3.xhr(url, …) / d3.json / d3.text / d3.xml / d3.htmlJS API → HTTPCaller-suppliedNone. No scheme or origin allowlist before request.open
d3.html response bodyHTTP → DOM fragmentNo — remote contentcreateContextualFragment, a Trusted Types sink. No sanitisation
Request headers via xhr.header(name, value)JS API → HTTPCaller-suppliedNone
d3.select(selector) / selectAllString → CSS selectorCaller-supplied; AppSheet concatenates a DOM id at manageGraphs.js:117, :343None. Passed to querySelector
selection.attr(name, value)JS API → DOM attributeCaller-suppliedd3.ns.qualify resolves namespaces (incl. xlink); no URL scheme filtering, so xlink:href / href accept javascript:
selection.style / .classed / .propertyJS API → DOMCaller-suppliedNone
d3.time.format(specifier) / locale stringsString → RegExpCaller-suppliedRegex construction — see ReDoS prompt

Trust Boundaries and Auth Assumptions

  • Authentication: None at the library layer. All four consuming views sit behind AppSheet‘s authenticated editor/admin session; D3 runs with that session’s full ambient authority in the browser.
  • Authorization: None at the library layer. D3 is a general-purpose DOM and network toolkit and applies no policy of its own.
  • Implicit trust: D3 assumes its caller has already decided what is safe. Every API above is intentionally unguarded — .html() is supposed to write raw HTML, d3.xhr is supposed to fetch any URL. The security boundary is the caller, and in AppSheet the relevant caller is nvd3, not AppSheet code.
  • Boundary crossings:
    1. App creator / end user authors a table name, column name, view name or spreadsheet cell value.
    2. AppSheet server surfaces it in stats / audit-log / performance data.
    3. manageGraphs.js maps it into nvd3 series[].key and point label.
    4. nvd3's tooltip contentGenerator passes it to d3.selection.html(), which assigns it to innerHTML — the sink in this repository. Step 4 is the trust boundary.

Mitigating controls (verify, do not assume)

  • CSP nonce. All four views load the bundle with nonce="@CspNonce", indicating a nonce-based CSP. A correct nonce CSP blocks injected <script> elements, and — relevant to prompt 1 — a policy without unsafe-eval blocks new Function outright. This limits, but does not eliminate, impact: overlay/UI-redress and markup-based data exfiltration remain, and the protection evaporates if script-src carries a permissive fallback.
  • Trusted Types. innerHTML, createContextualFragment and new Function are all TT-enforced sinks. Under enforcement each fails closed rather than executing. Confirm whether require-trusted-types-for 'script' is enforcing or report-only on each of the four consuming routes; if report-only, all three sinks are live.
  • Narrow current usage. AppSheet exercises only d3.select, d3.format and d3.time. The DSV codegen, the XHR family and d3.html have no caller in the product today.
  • Artefact is stock upstream. Because the fork did not modify d3.js / d3.min.js, there is no Google-authored code in the shipped bytes to review — a materially better position than the nvd3 fork, which inlines a pinned DOMPurify.

Sensitive Data Paths

Data TypeSourceDestinationProtection
App / table / column / view namesCustomer-authored app schemanvd3 series[].key → d3.selection.html() → innerHTMLNone in D3. DOMPurify on nvd3's legacy tooltip path only
Spreadsheet cell valuesCustomer / end-user data sourcenvd3 point label / value → .html() / SVG .text().text() paths are safe; .html() paths unprotected
End-user activity and identifiersAppSheet audit log_logDetailsPartial.cshtml chartsRendered in an admin/support session; same sinks
App usage statisticsAppSheet telemetryappStatsPartial.cshtml chartsSame sinks; cross-tenant viewers possible
Expression / performance tracesAppSheet app compiler & runtime_perfDetailsPartial.cshtml chartsSame sinks; traces may embed user formulas and column names
Session cookies / ambient credentialsBrowserAny d3.xhr destinationNone. No origin allowlist; not currently invoked by AppSheet

Privileged Actions

ActionLocationGuard
Compile a function from a string at runtimesrc/dsv/dsv.js:35 (new Function)None. JSON.stringify quoting of header names only
Assign an arbitrary string to innerHTMLsrc/selection/html.js:6-9None (raw-HTML API by design)
Parse remote HTML into a live DOM fragmentsrc/xhr/html.js (createContextualFragment)None
Outbound HTTP request to a caller-supplied URLsrc/xhr/xhr.js:94 (request.open)None. No scheme/origin allowlist
Set arbitrary request headerssrc/xhr/xhr.js:96 (setRequestHeader)None
Cross-origin request via legacy fallbacksrc/xhr/xhr.js:24-26 (XDomainRequest)Scheme regex /^(http(s)?:)?\/\// only
Set arbitrary DOM attributes, incl. xlink:href / hrefsrc/selection/attr.js via d3.ns.qualifyNone. No URL scheme filtering
Construct RegExp from caller-supplied format/locale stringssrc/locale/time-format.jsNone
Resolve a CSS selector built by string concatenationmanageGraphs.js:117, :343 (AppSheet side)None

Priority Review Areas

  1. new Function code generation in src/dsv/dsv.js — the one Google-relevant design defect in the shipped bundle. It is unreachable from AppSheet today and blocked by an enforcing CSP, but it is a runtime-codegen sink whose body is built from untrusted header text, and it is precisely the pattern AppSheet has already paid down elsewhere (jquery-ajax-unobtrusive). Recommendation: since AppSheet never calls d3.csv / d3.tsv, consider deleting src/dsv/ from the fork and rebuilding — the same “delete unused attack surface” reasoning the nvd3 model applies to nv.utils.pjax, and consistent with this fork's existing deletion-only posture. Note this would change d3.min.js, forfeiting the upstream-parity property, so weigh it deliberately.
  2. selection.html() reached by nvd3's unsanitised tooltip path. The sink is in this repository; the vulnerable caller is in the nvd3 fork. Neither document is actionable alone. Triage these two assets as a single unit, and fix at the nvd3 end (getTooltipContainer and the default contentGenerator), not here — D3's .html() is behaving as specified.
  3. d3.html + d3.xhr as a fetch-and-inject primitive. No origin controls, a Trusted Types sink on the parse side, ambient credentials on the fetch side, and no AppSheet caller. Same disposition question as src/dsv/: delete, or accept-and-document.
  4. Confirm CSP unsafe-eval absence and Trusted Types enforcement mode on the four consuming routes. These two controls are what currently bound the impact of items 1–3. If TT is report-only, all three sinks are live.
  5. Decade-old, end-of-life base. v3.4.2 (2014) on the EOL v3 line, carrying nvd3 v1.1.15b (2013) above it. No upstream security maintenance exists for either. The durable remediation is migrating these four legacy views off the nvd3/D3-v3 stack entirely; until then, both forks are frozen liabilities.
  6. Build reproducibility. Establish that d3.js / d3.min.js can be regenerated from src/ — pin smash and uglify-js, or record that the artefacts are accepted as upstream-verbatim and enforce that with an MD5 check in review.
  7. Selector construction at manageGraphs.js:117 / :343. AppSheet-side, but it is the only place AppSheet feeds a concatenated string into a D3 API. Confirm the graph id cannot carry attacker-controlled characters.

Out of Scope

  • nvd3. Tracked separately at https://gnocchi-internal.googlesource.com/third_party/nvd3 with its own THREAT_MODEL.md. Its tooltip sanitisation gap is the principal way untrusted data reaches D3‘s innerHTML sink, so the two documents are complementary and should be triaged together — but nvd3’s own code must be reviewed there.
  • jeenee/Nirvana/Scripts/app/plugins/nv.d3.indentedTree.js — a separately vendored file from a different nvd3 version, not produced by either fork. Review on the jeenee side.
  • lib/ — vendored demo dependencies (colorbrewer, science.js) and LICENSE-only directories. Excluded by .npmignore, verified absent from npm pack, not referenced by the Makefile, never served.
  • Makefile, bin/start, bin/uglify, and the smash / uglify-js devDependencies — build-time only. No CI executes them for this repository and they produce no automatically served artefact. Retained so the build remains auditable; see Priority Review Area 6.
  • src/start.js / src/end.js — the IIFE wrapper and version stamp generated from package.json.
  • Deleted upstream code — index.js (jsdom shim), test/, bower.json, component.json, composer.json, .travis.yml. Absent from main; review only if resurrected.
  • Server-side generation and authorisation of the app-stats, audit-log and performance data feeds, manageGraphs.js, the webpack d3Init.ts shim, and the CSP / Trusted Types middleware. All live in the jeenee repository and must be reviewed there; referenced here only as context and as mitigating controls to be verified.