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