| # Security Threat Model: element-resize-detector (AppSheet Fork) |
| |
| ## Asset Definition & Scope |
| |
| `element-resize-detector` is a small browser-only JavaScript library |
| (~1,550 lines across `src/`) that emits resize events for arbitrary DOM |
| elements, in browsers or situations where `ResizeObserver` is unavailable or |
| undesirable. Repository: |
| `sso://gnocchi-internal/third_party/element-resize-detector`, branch `main` |
| (mirrored at `appsheet-third-party/element-resize-detector`). |
| |
| - **Upstream:** `https://github.com/wnr/element-resize-detector`, npm version |
| `1.2.4`, upstream revision `bae322eff9057a532ec42e324f0ef05d4e509f5f` |
| (`last_upgrade_date` 2021-12-14, per `METADATA`). |
| - **Scope:** `src/` (the shipped implementation) and `dist/` (the prebuilt |
| browserify bundles `element-resize-detector.js` and |
| `element-resize-detector.min.js`). Build tooling (`Gruntfile.js`, |
| `karma.conf.js`), `test/`, and `examples/` are development-only. |
| - **How it works:** It detects resizes by *injecting hidden DOM into the |
| observed element* and listening for side effects. Two strategies exist: |
| - **`object` strategy (default):** appends a hidden |
| `<object type="text/html" data="about:blank">` — a same-origin nested |
| browsing context — into the target element and subscribes to the inner |
| window's native `resize` event. |
| - **`scroll` strategy:** appends a tree of absolutely-positioned, |
| overflow-scrolling `<div>`s plus a `<style>` element injected into |
| `document.head`, and infers size changes from scroll events and CSS |
| animation start events. |
| - **Deployment context:** Runs in the end user's browser as part of the |
| AppSheet web frontend. It is not used server-side. |
| |
| ## Consumption in AppSheet |
| |
| This fork is a **transitive** dependency, force-resolved to the Google fork via |
| an `overrides` entry in `jeenee/Nirvana/Content/package.json`: |
| |
| ```json |
| "react-sizeme": { |
| "element-resize-detector": "git+https://appsheet-third-party.googlesource.com/element-resize-detector#4a11fdb85cfe9f03d944f23b9f0231d454e02b53" |
| } |
| ``` |
| |
| `package-lock.json` resolves `node_modules/element-resize-detector` to that |
| exact commit (version `1.2.4`). Its only consumer is `react-sizeme@3.0.2`, whose |
| `withSize` higher-order component is imported by |
| `Nirvana/Content/components/app/primary/AdjustableDash.tsx` — the resizable |
| dashboard layout in the AppSheet app runtime. The library is therefore genuinely |
| executed in production end-user sessions, on customer app pages. |
| |
| ## The AppSheet Customization |
| |
| Two commits, both Trusted Types remediation |
| (`go/trusted-types-appsheet`), no functional divergence from upstream 1.2.4: |
| |
| | Commit | Change | |
| |---|---| |
| | `3a3dda5` "Initial import" | Import of upstream 1.2.4 **with the Trusted Types patch already applied to `src/`**: in `src/detection-strategy/scroll.js`, `injectScrollStyle`'s inner `injectStyle` sets `styleElement.textContent = style` instead of upstream's `styleElement.innerHTML = style`. | |
| | `4a11fdb` "Updated the dist folder" (HEAD) | Rebuilt `dist/element-resize-detector.js` and `dist/element-resize-detector.min.js`. The commit message notes the previously imported fork shipped **unpatched** built files — i.e. `src/` was fixed but the bundle consumers actually load was not. This commit closed that gap. | |
| |
| One upstream `innerHTML` sink deliberately remains, in |
| `src/browser-detector.js`: the legacy IE-version probe assigns a **static** |
| conditional-comment string |
| (`div.innerHTML = "<!--[if gt IE " + (++v) + "]><i></i><![endif]-->"`) to a |
| detached `<div>`. The interpolated value is a loop counter, not user data. |
| |
| ## Prioritization Signals |
| |
| - **1P OSS:** No (Third-party OSS maintained as an internal customized fork |
| for Google AppSheet) |
| - **1P Proprietary Shipped Software:** Yes (Bundled and minified into the |
| AppSheet frontend JavaScript shipped to browsers) |
| - **High-Risk Code Surface:** Yes — the library's core mechanism is |
| programmatic DOM injection into caller-supplied elements, creation of a |
| nested browsing context (`<object>`), injection of a `<style>` element into |
| `document.head`, cross-document event-listener registration, mutation of the |
| host page's inline styles, and `setTimeout` polling loops. |
| - **Perimeter Exposure:** Yes (AppSheet is a 1P service exposed to the public |
| Internet and customers; this code runs on customer-facing app pages) |
| - **Data Sensitivity:** Low — the library handles element geometry |
| (`offsetWidth`, `offsetHeight`, computed style) and internally generated |
| numeric IDs. It never reads element text, form values, or app data. Its |
| `reporter` may `console.warn` a *reference* to a DOM element when styles |
| conflict, which could surface page structure in browser logs. |
| - **Untrusted Input Handling:** Indirect / low. Customer-authored app data |
| never flows into this library as a string. All CSS it generates is built |
| from hardcoded literals and numbers derived from the layout engine. The |
| untrusted-input question is instead about *which element* and *which |
| options* the host application hands it. |
| - **Business Value:** Supports the resizable dashboard layout in the AppSheet |
| app runtime. Security impact is bounded: realistic failure modes are |
| same-origin script injection *if* the injected `<object>` URL ever became |
| caller-influenced, CSS/UI-redress issues from injected hidden elements, and |
| listener/detached-DOM leaks causing browser-side denial of service in |
| long-lived sessions. |
| |
| ## Scanning Harness Prompts |
| |
| 1. **Nested browsing context creation (`src/detection-strategy/object.js`).** |
| The `injectObject` path builds `document.createElement("object")`, sets |
| `object.type = "text/html"` and `object.data = "about:blank"`, then appends |
| it into the caller's element. `about:blank` inherits the embedding page's |
| origin, so its `contentDocument` is fully same-origin. Verify `object.data` |
| can never be influenced by caller options, element attributes, or app |
| configuration — a controllable `data` value here is a direct same-origin |
| script-injection primitive. Also verify the element is not given |
| `allow-scripts`-equivalent capabilities beyond the default and that |
| `object.tabIndex = -1` / `aria-hidden="true"` remain set so the hidden node |
| cannot be focused or announced. |
| 2. **DOM / style injection sinks.** Audit every write to `innerHTML`, |
| `textContent`, `style.cssText`, `className`, and `setAttribute` in `src/`: |
| - `scroll.js:injectScrollStyle` → `styleElement.textContent` (the patched |
| sink — confirm it has not regressed to `innerHTML` and that `dist/` |
| matches `src/`). |
| - `scroll.js:getScrollbarSizes`, `scroll.js:storeStartSize`, |
| `scroll.js:initListeners`, and `object.js:injectObject` → repeated |
| `element.style.cssText = buildCssTextString([...])` assignments. |
| Confirm every interpolated value is either a hardcoded literal or a |
| number originating from `offsetWidth`/`offsetHeight`, never a string |
| from the caller. |
| - `browser-detector.js` → the remaining static-string `innerHTML` |
| assignment. Confirm the interpolated value stays a loop counter. |
| - `scroll.js` CSS rule construction interpolates `containerClass`; confirm |
| `detectionContainerClass` remains the hardcoded |
| `"erd_scroll_detection_container"` constant and never becomes an option. |
| 3. **Event-listener and timer leaks (denial of service).** Trace |
| `element-resize-detector.js:listenTo` / |
| `element-resize-detector.js:uninstall` against |
| `object.js:addListener`/`object.js:uninstall` and |
| `scroll.js:addListener`/`scroll.js:uninstall`. Check that: every |
| `addEventListener`/`attachEvent` has a matching removal; the |
| `resize` listener registered on |
| `object.contentDocument.defaultView` is detached before the `<object>` node |
| is removed; the `state.checkForObjectDocumentTimeoutId` polling loop in |
| `object.js:onObjectLoad`→`getDocument` is always cleared; and |
| `listener-handler.js:removeAllListeners` plus |
| `state-handler.js` do not retain references to detached elements. Unbounded |
| growth here is a realistic client-side DoS in long-lived AppSheet sessions |
| that mount and unmount dashboards repeatedly. |
| 4. **Host-page style mutation / UI redress.** Both strategies call |
| `element.style.setProperty("position", "relative", important)` and may zero |
| out `top`/`right`/`bottom`/`left` on statically positioned targets |
| (`object.js:alterPositionStyles`, `scroll.js:alterPositionStyles`). Review |
| whether the `important: true` option, combined with the injected |
| absolutely-positioned containers, can be used to overlay or displace |
| interactive UI (clickjacking / UI-redress) on a customer app page. |
| 5. **Reentrancy and unbounded recursion.** The scroll strategy re-enters |
| `updateChildSizes`/`positionScrollbars` from its own scroll and |
| animationstart handlers. Check the batch processor |
| (`batch-processor@1.0.0`) and the `updateDetectorElements` path for |
| feedback loops that could spin the main thread when an element is resized by |
| its own resize listener. |
| 6. **`dist/` vs `src/` drift.** `dist/` is a checked-in browserify bundle and |
| is what downstream tooling may load. Commit `4a11fdb` exists precisely |
| because these fell out of sync and shipped an unpatched `innerHTML`. Flag |
| any state where `dist/` does not reflect the current `src/`. |
| |
| ## Entry Points and Untrusted Inputs |
| |
| | Entry Point | Type | Trusted? | Validation | |
| |---|---|---|---| |
| | `elementResizeDetectorMaker(options)` (`src/element-resize-detector.js`) | Factory / configuration | Yes (called by `react-sizeme`, first-party bundle code) | `getOption` applies defaults; `strategy` is compared against the literals `"scroll"`/`"object"` and silently falls back to `object`; `important`, `debug`, `callOnAdd` coerced with `!!` | |
| | `erd.listenTo([options,] elements, callback)` | DOM element handle + callback | Yes (first-party caller supplies the element) | `isElement()` checks `nodeType === 1`; `isCollection()` normalises array-likes; non-elements are rejected via `reporter.error` | |
| | `erd.uninstall(elements)` / `erd.removeListener` / `erd.removeAllListeners` | Teardown API | Yes | Element state existence checked before teardown | |
| | `options.idHandler` / `options.reporter` / `options.batchProcessor` | Injected strategy objects (callable hooks) | Yes — but they are arbitrary caller-supplied code executed inside the library | None. A hostile or buggy implementation runs with full page privileges; safety rests on these being first-party. | |
| | Browser layout values (`offsetWidth`, `offsetHeight`, `getComputedStyle`) | Ambient DOM / layout engine | Yes | Used as numbers in CSS pixel strings; no string interpolation from user content | |
| | Native `resize` / `scroll` / `animationstart` events on injected nodes | Browser event stream | Yes | Handlers only read geometry from the injected nodes | |
| |
| **Net assessment:** there is no path by which customer-authored app data reaches |
| this library as a string. The genuine trust dependency is that the *host |
| application* passes only its own elements and its own option objects. |
| |
| ## Trust Boundaries and Auth Assumptions |
| |
| - **Authentication**: None. Client-side DOM utility with no network or |
| credential handling. |
| - **Authorization**: None. Any script in the page can call the exported |
| factory; there is nothing to authorise. |
| - **Implicit trust**: (a) The caller supplies elements and options it owns. |
| (b) `about:blank` is and remains the only URL loaded into the injected |
| `<object>`. (c) Injected hidden nodes are inert and will not be traversed or |
| styled by application CSS. (d) `document.head` is available and writable for |
| `<style>` injection. |
| - **Boundary crossings**: The one real boundary crossing is |
| *parent document → injected `<object>` nested browsing context*. |
| Because the document is `about:blank`, the child inherits the parent origin |
| and the library reads `object.contentDocument` and attaches a listener to |
| `contentDocument.defaultView` directly. This is same-origin by design; it |
| would become a security boundary violation only if the `data` URL were ever |
| made caller-controllable or cross-origin. |
| |
| ## Sensitive Data Paths |
| |
| | Data Type | Source | Destination | Protection | |
| |---|---|---|---| |
| | Element geometry (`offsetWidth`, `offsetHeight`, computed `position`/`top`/`right`/`bottom`/`left`) | Browser layout engine, via `getComputedStyle` in `scroll.js:storeStartSize` and `object.js` | In-memory element state (`state-handler.js`) and interpolated into generated CSS pixel strings | Numeric values only; never persisted or transmitted | |
| | Detector element IDs | `id-generator.js` (monotonic counter) | Element state and injected `<style>` element `id` | Sequential integers; not derived from and not correlatable to customer data | |
| | DOM element references | Caller (`react-sizeme` → `AdjustableDash.tsx`) | `reporter.warn`/`reporter.log` → browser console when style conflicts or debug mode are active | Console-only, client-side; could surface page structure in user-collected logs. No app data, credentials, or PII. | |
| |
| No secrets, tokens, credentials, PII, or customer app content pass through this |
| library. |
| |
| ## Privileged Actions |
| |
| | Action | Location | Guard | |
| |---|---|---| |
| | Create and append a nested browsing context (`<object type="text/html" data="about:blank">`) into a caller element | `src/detection-strategy/object.js:injectObject` (inner `mutateDom`) | URL is the hardcoded literal `"about:blank"`; node is marked `tabIndex = -1` and `aria-hidden="true"`; IE ordering handled via `browserDetector.isIE()` | |
| | Register a listener on another document's window | `src/detection-strategy/object.js:addListener` (`object.contentDocument.defaultView.addEventListener("resize", listenerProxy)`) | Same-origin `about:blank` child only; removal depends on `object.js:uninstall` being called | |
| | Inject a `<style>` element into `document.head` | `src/detection-strategy/scroll.js:injectScrollStyle` (inner `injectStyle`) | Written via `textContent` (Trusted Types patch, not `innerHTML`); guarded by a `getElementById(styleId)` idempotency check; rule text built only from hardcoded constants and the fixed `detectionContainerClass` | |
| | Bulk `style.cssText` assignment on injected and host elements | `src/detection-strategy/scroll.js:getScrollbarSizes`, `:storeStartSize`, `:initListeners`; `src/detection-strategy/object.js:injectObject` | `buildCssTextString` joins hardcoded rule literals and numeric pixel values; no caller strings interpolated | |
| | Mutate the host element's inline position styles | `src/detection-strategy/object.js:alterPositionStyles`, `src/detection-strategy/scroll.js:alterPositionStyles` (`element.style.setProperty`) | Applied only when computed `position` is `static`; `!important` applied only when `options.important` is set; conflicts reported via `reporter.warn` | |
| | Schedule recurring timers | `src/detection-strategy/object.js:onObjectLoad` → `getDocument` (`setTimeout(..., 100)` poll for `contentDocument`) | Timer id stored in element state and cleared on re-entry and in `object.js:uninstall` | |
| | Remove injected nodes and all listeners | `src/element-resize-detector.js:uninstall` → `listener-handler.js:removeAllListeners`, strategy `uninstall` | Element state existence checked; correctness of full teardown is a priority review area | |
| |
| ## Priority Review Areas |
| |
| 1. **`<object>` injection in `src/detection-strategy/object.js` (highest |
| value).** This is the only place the library creates a new browsing context. |
| `data` is the hardcoded `"about:blank"` today, and because that inherits the |
| embedding origin, anything that made the URL caller-influenceable — an |
| option, an attribute read off the target element, an app-config value — |
| would turn a layout utility into a same-origin script-injection sink. |
| Confirm the literal is unreachable from configuration and that the injected |
| node cannot be re-pointed after insertion. |
| 2. **Trusted Types patch integrity and `src`/`dist` parity.** The entire reason |
| this fork exists is `scroll.js:injectScrollStyle` using `textContent` |
| instead of `innerHTML`, and commit `4a11fdb` exists because `dist/` once |
| shipped without that fix. Verify the patch is present in `src/` **and** in |
| both `dist/` bundles, and treat any reintroduction of `innerHTML` in |
| `src/detection-strategy/` as a release blocker. The residual static-string |
| `innerHTML` in `src/browser-detector.js` should be confirmed unreachable |
| with non-literal data (and is a candidate for removal, since the IE probe is |
| dead code in supported browsers). |
| 3. **Style and attribute construction across both strategies.** Every generated |
| CSS string flows through `buildCssTextString` into `style.cssText`, and CSS |
| rule text in `injectScrollStyle` interpolates a class name. These are the |
| library's string-building hot spots. Confirm all inputs remain hardcoded |
| literals or layout-engine numbers, and that `detectionContainerClass` is not |
| promoted to a caller option — a caller-supplied class name would become a |
| CSS-injection sink inside a live `<style>` element. |
| 4. **Listener and detached-DOM leaks (client-side DoS).** Cross-document |
| listeners on `object.contentDocument.defaultView`, the |
| `checkForObjectDocumentTimeoutId` polling loop, scroll/animationstart |
| handlers on injected nodes, and the element→state map in |
| `state-handler.js` all persist until `uninstall` runs. AppSheet mounts and |
| unmounts dashboards repeatedly within a single session, so incomplete |
| teardown accumulates detached DOM and listeners and degrades or hangs the |
| tab. Verify `react-sizeme`'s unmount path actually reaches |
| `erd.uninstall`. |
| 5. **Host-page layout mutation and UI redress.** The library forcibly sets |
| `position: relative` (optionally `!important`) on observed elements and |
| inserts absolutely-positioned hidden containers, including one temporarily |
| inserted at `document.body.firstChild` in `scroll.js:getScrollbarSizes`. |
| Review for overlay/clickjacking potential and for interference with |
| AppSheet's own z-index and modal stacking on customer app pages. |
| 6. **Caller-supplied hooks (`idHandler`, `reporter`, `batchProcessor`).** These |
| options let arbitrary functions execute inside the library's DOM-mutating |
| code paths with full page privileges. Confirm AppSheet (via `react-sizeme`) |
| supplies only defaults, and that none of these can be reached from |
| app-author-controlled configuration. |
| |
| ## 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. |