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