| # Security Threat Model: react-draggable (AppSheet Fork) |
| |
| ## Asset Definition & Scope |
| |
| - **Component:** `react-draggable` — a React component (`<Draggable>` and the |
| lower-level `<DraggableCore>`) that makes a single child element draggable |
| via mouse and touch events, applying a CSS `transform: translate(...)` (or |
| an SVG `transform` attribute) to reposition it. |
| - **Repository:** Git-on-Borg |
| (`https://gnocchi-internal.googlesource.com/third_party/react-draggable`, |
| branch `main`). Mirrored at |
| `https://appsheet-third-party.googlesource.com/react-draggable`, which is the |
| URL AppSheet's `package.json` actually resolves. |
| - **Upstream:** `react-grid-layout/react-draggable` v`4.4.5` (2022-04-26). |
| `METADATA` records |
| `version: 44a8c6ed103ec6c0a4dda5faf7f8ebca16f9b325`, |
| `last_upgrade_date 2022-04-26`. |
| - **Scope:** Flow-typed sources under `lib/` (`Draggable.js`, |
| `DraggableCore.js`, `utils/domFns.js`, `utils/positionFns.js`, |
| `utils/getPrefix.js`, `utils/shims.js`, `utils/types.js`, `utils/log.js`, |
| `cjs.js`) **and** the checked-in transpiled output under `build/` |
| (`build/cjs/**`, `build/web/react-draggable.min.js`). Runtime dependencies |
| are `clsx` and `prop-types`. |
| - **Deployment context:** Browser-side only. Bundled into the AppSheet app |
| runtime and served to end users over the public Internet. AppSheet does |
| **not** import `react-draggable` directly anywhere in |
| `Nirvana/Content`; it is consumed **transitively**, pinned via |
| `package.json` `overrides` for two packages: |
| - `react-grid-layout@0.16.6` — used by |
| `Nirvana/Content/components/app/primary/AdjustableDash.tsx` (end-user |
| dashboard layout editing) and |
| `Nirvana/Content/components/app/ImageGallery.tsx` (responsive image |
| gallery). |
| - `react-resizable` — resize handles within the same grid surfaces. |
| |
| Both consumers render **customer-authored app content** (dashboard view |
| names, image assets, layout definitions) inside the AppSheet runtime, so the |
| surrounding DOM is attacker-influenceable by whoever authored the app. |
| |
| ### AppSheet fork deltas vs. upstream |
| |
| | Commit | Change | |
| | --- | --- | |
| | `d625fe2` "Initial import" | Imports upstream `4.4.5` with a **Trusted Types compliance patch already applied** to `lib/utils/domFns.js:addUserSelectStyles`: upstream assigns the drag-time user-select CSS with `styleEl.innerHTML = ...` / `+=`, which violates a `require-trusted-types-for 'script'`/`TrustedHTML` policy and trips DOM-XSS sinks; the fork uses `styleEl.textContent` instead. Functionally equivalent for a `<style>` element, but it removes an `innerHTML` sink. | |
| | `f3d2c0f` "[nwd] Pushing the build folder with the trusted types patched built files" | Un-gitignores and checks in `build/cjs/**` and `build/web/react-draggable.min.js(.map)` so that AppSheet consumes the patched transpiled output directly from the Git dependency rather than rebuilding it. | |
| | `c67f829` "[NWD] Remove scripts from package.json" (HEAD) | Deletes the entire `scripts` block (`test`, `build`, `lint`, `flow`, …) so that `npm ci` does not attempt to rebuild the package on every install. | |
| |
| The net effect of `f3d2c0f` + `c67f829` is that **`build/` is the shipped |
| artifact and there is no longer any in-repo path that regenerates it from |
| `lib/`**. Sources and build output can silently diverge; see *Priority Review |
| Areas*. |
| |
| ## Prioritization Signals |
| |
| - **1P OSS:** No (third-party OSS library maintained as an internal customized |
| fork for Google AppSheet). |
| - **1P Proprietary Shipped Software:** Yes (the checked-in `build/cjs/**` is |
| bundled into the minified JavaScript AppSheet ships to end-user browsers). |
| - **High-Risk Code Surface:** **No.** This is a UI interaction library. It has |
| no network I/O, no filesystem or process access, no serialization/ |
| deserialization of untrusted formats, no cryptography, and no authentication |
| or authorization logic. Its entire footprint is DOM measurement, pointer/ |
| touch event handling, and arithmetic on coordinates. The realistic |
| worst-case impact is a client-side DOM-manipulation or CSS-injection defect, |
| or a UI-redressing (clickjacking-adjacent) primitive — not remote code |
| execution or data exfiltration. Severity should be assessed accordingly and |
| **not** inflated to match the perimeter-exposure signal. |
| - **Perimeter Exposure:** Yes (AppSheet is a 1P service exposed to the public |
| Internet and customers; this code executes in end-user browsers on |
| customer-authored app content). |
| - **Data Sensitivity:** Low. The library handles only pointer coordinates, |
| touch identifiers, CSS class names, and element geometry. It never reads |
| form values, cookies, storage, or credentials. Coordinate data is passed to |
| caller callbacks and, in AppSheet's case, persisted as dashboard layout |
| geometry. |
| - **Untrusted Input Handling:** Yes, but low-severity. Inputs are |
| (a) browser-generated `MouseEvent`/`TouchEvent` objects, (b) caller-supplied |
| props including two **CSS selector strings** (`handle`, `cancel`) that are |
| fed to `querySelector`-family APIs, (c) `bounds` which may itself be a CSS |
| selector string, (d) `positionOffset` values which may be **arbitrary |
| strings interpolated into a CSS `transform` value**, and (e) DOM nodes |
| supplied via `nodeRef` / `offsetParent`. |
| - **Business Value:** Underpins AppSheet's dashboard layout editor and image |
| gallery. A defect here degrades UI correctness or availability of those |
| views. It is included in Fortify scope primarily because it is a |
| Google-modified fork that ships to the browser, not because it is a |
| high-value attack target. |
| |
| ## Scanning Harness Prompts |
| |
| Direct the agentic code scanner as follows. **Calibrate severity to a UI |
| library**: the absence of a server, of network calls, and of any secret material |
| means most findings will be correctness or DoS issues rather than exploitable |
| vulnerabilities. Report inflated-severity findings as informational. |
| |
| 1. **Verify the Google Trusted Types patch has not regressed.** In |
| `lib/utils/domFns.js:addUserSelectStyles` **and** its transpiled twin |
| `build/cjs/utils/domFns.js`, confirm the CSS text is assigned via |
| `textContent` and never `innerHTML`, `insertAdjacentHTML`, |
| `outerHTML`, or `CSSStyleSheet.insertRule` with interpolated data. This is |
| the single Google-authored security change in the repo. |
| 2. **Diff `lib/` against `build/`.** The build is checked in and the build |
| scripts have been removed, so the two can drift. Flag any semantic |
| divergence — especially any place where `build/` retains an `innerHTML` |
| assignment that `lib/` has fixed, or vice versa. Also scan |
| `build/web/react-draggable.min.js` as shipped minified 1P JavaScript. |
| 3. **CSS-injection / transform-string construction in |
| `lib/utils/domFns.js:getTranslation` (and its callers |
| `createCSSTransform`, `createSVGTransform`).** `positionOffset.x` and |
| `positionOffset.y` are interpolated **as raw strings** into |
| `translate(${defaultX}, ${defaultY})` when the caller passes strings rather |
| than numbers. Determine whether a caller-controlled `positionOffset` string |
| can break out of the `translate()` function and inject additional CSS |
| declarations or a `url(...)` reference into the element's inline `transform` |
| style. React's inline-`style` handling provides some protection, but the |
| value reaches the style object pre-assembled. Confirm AppSheet's consumers |
| (`react-grid-layout`, `react-resizable`) never route customer data into |
| `positionOffset`. |
| 4. **Prototype pollution.** Audit every object-spread and dynamic key write for |
| unguarded `__proto__` / `constructor` / `prototype` keys. Specific sites: |
| `createCSSTransform` builds an object with a **computed key** |
| (`{[browserPrefixToKey('transform', browserPrefix)]: translation}`); |
| `getPrefix.js:browserPrefixToKey` / `kebabToTitleCase` derive that key from a |
| string; `Draggable.render` performs |
| `style: {...children.props.style, ...style}` and |
| `{...draggableCoreProps}`; `domFns.js:addEvent`/`removeEvent` write |
| `el['on' + event] = handler` on a legacy fallback path. Note that |
| `browserPrefix` is derived from `window.document.documentElement.style`, not |
| from user input, so the computed-key path is not currently |
| attacker-reachable — verify that remains true. |
| 5. **Selector handling in `lib/utils/domFns.js:matchesSelector` / |
| `matchesSelectorAndParentsTo` and |
| `lib/utils/positionFns.js:getBoundPosition`.** The `handle`, `cancel`, and |
| string-valued `bounds` props are passed unvalidated to |
| `el.matches(selector)` and `ownerDocument.querySelector(bounds)`. Check for |
| (a) unhandled `SyntaxError` on a malformed selector causing a render/drag |
| crash, and (b) unbounded tree walks in `matchesSelectorAndParentsTo`, which |
| loops to `document` when `baseNode` is not an ancestor of `el`. |
| 6. **DoS / unbounded loops and NaN propagation.** `positionFns.js:snapToGrid` |
| divides by `grid[0]`/`grid[1]` (a zero grid yields `Infinity`/`NaN`); |
| `createDraggableData` divides deltas by `props.scale` (`scale: 0` yields |
| `Infinity`). Trace whether `NaN`/`Infinity` coordinates reach the CSS |
| transform and wedge layout. Also check the |
| `while (node)` loop in `matchesSelectorAndParentsTo` and the retry loop in |
| `DraggableCore.handleDrag` (which synthesizes a `new MouseEvent('mouseup')` |
| to self-terminate) for termination guarantees. |
| 7. **Event-listener lifecycle and memory safety in `lib/DraggableCore.js`.** |
| `handleDragStart` attaches `mousemove`/`mouseup` (and touch equivalents) |
| listeners to `ownerDocument` with `{capture: true}`. Verify every path |
| through `handleDragStop` and `componentWillUnmount` removes them |
| symmetrically, including the early-return paths and the `mounted === false` |
| race. Leaked document-level capture listeners are both a memory leak and a |
| latent input-hijacking surface. |
| 8. **`ReactDOM.findDOMNode` usage.** Both `Draggable.findDOMNode` and |
| `DraggableCore.findDOMNode` fall back to the deprecated |
| `ReactDOM.findDOMNode(this)` when no `nodeRef` is supplied, reaching outside |
| the component's own subtree. Confirm this cannot be steered to a node the |
| component does not own. |
| 9. **Debug logging.** `lib/utils/log.js` gates `console.log` on |
| `process.env.DRAGGABLE_DEBUG`, which |
| `babel-plugin-transform-inline-environment-variables` inlines at build time. |
| Confirm the shipped `build/` output has this compiled out and does not log |
| `%j`-serialized DOM nodes and coordinates to end-user consoles. |
| |
| ## Entry Points and Untrusted Inputs |
| |
| | Entry Point | Type | Trusted? | Validation | |
| |---|---|---|---| |
| | `<Draggable>` / `<DraggableCore>` props (`axis`, `bounds`, `grid`, `scale`, `position`, `defaultPosition`, `positionOffset`, `handle`, `cancel`, `disabled`, `allowAnyClick`, `enableUserSelectHack`, `nodeRef`, `offsetParent`) | React props from calling application code (`react-grid-layout`, `react-resizable`) | Partially — callers are npm packages driven by AppSheet code and customer-authored app layout data | `PropTypes` runtime shape checks only, including a custom `DraggableCore.propTypes.children` validator. `PropTypes` are stripped in production builds and are **not** a security control. No sanitisation of selector strings or `positionOffset` strings. | |
| | `mousedown`/`mousemove`/`mouseup`, `touchstart`/`touchmove`/`touchend` events → `DraggableCore.handleDragStart`, `handleDrag`, `handleDragStop` | Browser-generated DOM events on the document and on the draggable node | No — user/attacker controls timing, target, button, and touch identifiers | `allowAnyClick` gates on `e.button !== 0`; `e.target instanceof ownerDocument.defaultView.Node` type check; `handle`/`cancel` selector matching; touch-identifier matching in `positionFns.getControlPosition` / `domFns.getTouch` | |
| | `handle`, `cancel`, and string-valued `bounds` props | CSS selector strings | No | Passed unvalidated to `el.matches(...)` (`domFns.matchesSelector`) and `ownerDocument.querySelector(...)` (`positionFns.getBoundPosition`). A malformed selector throws; `getBoundPosition` throws an explicit `Error` when the selector matches nothing. | |
| | `positionOffset: {x, y}` | Numbers **or arbitrary strings** | No | Strings are interpolated verbatim into the CSS `transform` value by `domFns.getTranslation`. **No escaping.** | |
| | `nodeRef`, `offsetParent` props | Caller-supplied DOM node references | Partially | `DraggableCore.propTypes.offsetParent` is a custom validator that throws `"Draggable's offsetParent must be a DOM Node."` when `nodeType !== 1`, but `propTypes` are stripped in production builds, so at runtime the node is used unchecked in `positionFns.getControlPosition` → `domFns.offsetXYFromParent` | |
| | Ambient DOM/CSSOM state (`getComputedStyle`, `clientHeight`, `offsetLeft`, `scrollLeft`, `getBoundingClientRect`) | Read from the host page | No — customer-authored app content can influence layout and computed styles | Coerced through `shims.int()` (`parseInt(..., 10)`), which yields `NaN` on non-numeric input rather than throwing | |
| | `window.document.documentElement.style` | Browser feature detection | Yes | `getPrefix.js` guards for `typeof window === 'undefined'` and optional-chains `window.document?.documentElement?.style` | |
| |
| ## Trust Boundaries and Auth Assumptions |
| |
| - **Authentication**: None, and none is applicable. This library performs no |
| network I/O and makes no identity assertions. |
| - **Authorization**: None at the library level. Whether a given AppSheet user |
| may rearrange a dashboard is decided entirely by AppSheet's own permission |
| model; `disabled` here is a UI affordance, **not** an access control. |
| - **Implicit trust**: |
| - Trusts that its caller supplies well-formed props — in particular that |
| selector strings are valid CSS and that `positionOffset` strings are not |
| attacker-controlled. |
| - Trusts the host page's DOM: it reads geometry from `offsetParent`, |
| `ownerDocument`, and `getComputedStyle`, all of which customer-authored |
| app content can influence. |
| - Trusts that `React.Children.only(children)` receives a single element |
| that tolerates having `className`, `style`, and `transform` overwritten |
| via `React.cloneElement`. |
| - Explicitly handles the iframe case (`doc` is threaded through |
| `addUserSelectStyles`/`removeUserSelectStyles`, and `ownerDocument` is |
| used rather than the global `document`), so it may operate across |
| same-origin document boundaries. |
| - **Boundary crossings**: The only meaningful one is |
| **untrusted user gesture → DOM mutation in the AppSheet origin**. There is |
| no client→server boundary inside this library; any persistence of the |
| resulting coordinates is performed by AppSheet's own code, which must |
| re-validate that data server-side. |
| |
| ## Sensitive Data Paths |
| |
| | Data Type | Source | Destination | Protection | |
| |---|---|---|---| |
| | Pointer / touch coordinates (`clientX`, `clientY`, `identifier`) | Browser input events | `DraggableCore` component state → `positionFns.createCoreData` / `createDraggableData` → caller `onStart`/`onDrag`/`onStop` callbacks | Transient in-memory only; never persisted or transmitted by this library | |
| | Element geometry (`offsetLeft`, `clientWidth`, computed padding/border/margin) | Host DOM via `getComputedStyle` and layout properties | `positionFns.getBoundPosition` bounds arithmetic | Read-only measurement; coerced via `shims.int()` | |
| | Layout position (`x`, `y`) | Drag interaction | Inline CSS `transform` on the child element, and caller callbacks (AppSheet persists these as dashboard layout geometry) | Values are numeric unless the caller injects `positionOffset` strings; server-side layout validation is AppSheet's responsibility | |
| | CSS class names | `defaultClassName`, `defaultClassNameDragging`, `defaultClassNameDragged` props and `children.props.className` | `clsx(...)` → child element `className` | Assigned through React's `className` prop (attribute-escaped), or via `el.className +=` / regex replace in the legacy `domFns.addClassName`/`removeClassName` fallback for browsers lacking `classList` | |
| | Drag-time user-select stylesheet | Hard-coded constant string | `<style id="react-draggable-style-el">` appended to `document.head` by `domFns.addUserSelectStyles` | Assigned via `textContent` (Google patch); content is a compile-time constant with no interpolation | |
| | **No** secrets, credentials, tokens, cookies, or PII | — | — | The library reads none | |
| |
| ## Privileged Actions |
| |
| | Action | Location | Guard | |
| |---|---|---| |
| | Inject a `<style>` element into `document.head` | `lib/utils/domFns.js:addUserSelectStyles` | Idempotent on `#react-draggable-style-el`; content is a hard-coded constant assigned via `textContent` (Google Trusted Types patch); gated on `props.enableUserSelectHack` | |
| | Mutate `document.body` class list | `domFns.addClassName` / `removeClassName`, invoked from `addUserSelectStyles` / `removeUserSelectStyles` | Fixed class name `react-draggable-transparent-selection` | |
| | Attach document-level capturing event listeners | `DraggableCore.handleDragStart` → `domFns.addEvent(ownerDocument, ..., {capture: true})` | Removed in `handleDragStop` and `componentWillUnmount`; legacy fallback assigns `el['on' + event]` directly | |
| | Clear the user's text selection | `domFns.removeUserSelectStyles` (`selection.removeAllRanges()`) | Skipped when `selection.type === 'Caret'`; wrapped in `try/catch` | |
| | Write inline CSS `transform` / SVG `transform` on the child element | `Draggable.render` → `domFns.createCSSTransform` / `createSVGTransform` → `domFns.getTranslation` | None on string-valued `positionOffset`; numeric values get a `px` suffix | |
| | Overwrite the child's `className`, `style`, and `transform` props | `Draggable.render` (`React.cloneElement(React.Children.only(children), {...})`) | `React.Children.only` enforces exactly one child; existing `style` is spread-merged and can be shadowed | |
| | Reach outside the component subtree for a DOM node | `Draggable.findDOMNode` / `DraggableCore.findDOMNode` (`ReactDOM.findDOMNode(this)`) | Bypassed when the caller supplies `nodeRef` | |
| | Synthesize and dispatch a drag-stop | `DraggableCore.handleDrag` (`this.handleDragStop(new MouseEvent('mouseup'))`) | Triggered only when bound-position math detects an aborted drag | |
| | Write to the browser console | `lib/utils/log.js:log` | Gated on `process.env.DRAGGABLE_DEBUG`, inlined at build time | |
| |
| ## Priority Review Areas |
| |
| 1. **Source/build divergence — the highest-value review target.** `f3d2c0f` |
| checked `build/` into the repo and `c67f829` removed every `scripts` entry |
| from `package.json`, so the artifact AppSheet actually loads |
| (`build/cjs/cjs.js`, per the `main` field) is **never regenerated from |
| `lib/`**. A reviewer or a scanner that only reads `lib/` will not be |
| auditing the shipped code. Confirm `build/cjs/**` is a faithful transpilation |
| of the current `lib/**` — in particular that |
| `build/cjs/utils/domFns.js:addUserSelectStyles` carries the same |
| `textContent` (not `innerHTML`) assignment — and treat |
| `build/web/react-draggable.min.js` as shipped 1P minified JavaScript in its |
| own right. |
| |
| 2. **Regression-proofing the Trusted Types patch |
| (`lib/utils/domFns.js:addUserSelectStyles`).** This is the only |
| Google-authored security modification in the fork: it replaces upstream's |
| `styleEl.innerHTML = ...` with `styleEl.textContent = ...` to satisfy |
| Trusted Types and to remove an `innerHTML` sink. Because the patch lives in |
| the "Initial import" commit rather than in a labelled follow-up, a naive |
| upstream re-import would silently revert it. Any future version bump must |
| re-apply and re-verify this change in both `lib/` and `build/`. |
| |
| 3. **String interpolation into CSS `transform` |
| (`lib/utils/domFns.js:getTranslation`).** When `positionOffset.x`/`.y` are |
| strings they are concatenated unescaped into |
| `translate(${defaultX}, ${defaultY})`. This is the library's only |
| string-building-into-a-DOM-sink path. Establish whether the resulting value |
| can carry additional CSS declarations, and confirm that AppSheet's |
| consumers (`react-grid-layout@0.16.6`, `react-resizable`) never derive |
| `positionOffset` from customer-authored app definitions. Realistic impact is |
| CSS injection / UI redressing, not script execution — React assigns the |
| value through the inline `style` object, which does not evaluate JavaScript. |
| |
| 4. **Prototype pollution via computed keys and object spreads.** Review |
| `createCSSTransform`'s computed-key object literal, the |
| `browserPrefixToKey`/`kebabToTitleCase` string derivation that produces that |
| key, the `{...children.props.style, ...style}` and `{...draggableCoreProps}` |
| spreads in `Draggable.render`, and the |
| `el['on' + event] = handler` legacy assignment in `domFns.addEvent`. Today |
| the key is derived from browser feature detection rather than user input, so |
| no attacker-reachable pollution path is apparent — the review objective is |
| to confirm that invariant and to catch any future change that makes the |
| prefix or event name caller-controlled. |
| |
| 5. **Unvalidated CSS selectors and unbounded DOM traversal |
| (`domFns.matchesSelector`, `matchesSelectorAndParentsTo`, |
| `positionFns.getBoundPosition`).** `handle`, `cancel`, and string `bounds` |
| reach `matches()`/`querySelector()` with no validation; malformed input |
| throws a `SyntaxError` mid-drag, and `getBoundPosition` throws an explicit |
| `Error` when a bounds selector matches nothing. Neither is caught. Assess |
| the availability impact on the AppSheet dashboard editor and whether the |
| `while (node)` ancestor walk can be made to traverse the full document on |
| every pointer event. |
| |
| 6. **Event-listener symmetry and unmount races in `lib/DraggableCore.js`.** |
| Document-level capturing `mousemove`/`mouseup`/`touchmove`/`touchend` |
| listeners are added in `handleDragStart` and removed in `handleDragStop` / |
| `componentWillUnmount`. Verify no early-return path (the `shouldUpdate === |
| false` / `this.mounted === false` checks, the synthesized |
| `new MouseEvent('mouseup')` self-stop, the `nodeRef`-is-null case) can leave |
| a listener attached to the document after unmount. Leaked capture-phase |
| listeners on `document` intercept input site-wide and are the most plausible |
| genuine security-relevant defect in this component. |
| |
| 7. **Numeric edge cases producing `NaN`/`Infinity` |
| (`positionFns.snapToGrid`, `createDraggableData`, `shims.int`).** A `grid` |
| containing `0`, or `scale: 0`, produces non-finite coordinates that flow |
| into the CSS transform. Low severity (client-side layout corruption / |
| hang), but cheap to confirm. |
| |
| 8. **Stale upstream baseline.** The fork tracks `4.4.5` (April 2022). Review |
| upstream releases since then for correctness and security fixes, and weigh |
| the cost of re-importing against the risk of losing the Trusted Types patch |
| (see item 2). |
| |
| ## Out of Scope |
| |
| - `react-grid-layout`, `react-resizable`, `clsx`, and `prop-types` — resolved |
| from the public npm registry and covered by dependency scanning rather than |
| by this source target. |
| - Server-side validation of persisted dashboard layout geometry, which lives |
| in the `jeenee` repository. |
| - `example/`, `specs/`, `karma*.conf.js`, `webpack.config.js`, `Makefile`, |
| `.travis.yml`, and `appveyor.yml` — development and test scaffolding that is |
| not shipped (`package.json` `files` restricts the published surface to |
| `/build`, `/typings`, and the `web/` bundles). |
| - Teams can extend this section with any known filed vulnerability bugs that |
| they have determined should be out of scope from future Fortify bug filing. |