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:

"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:

CommitChange
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 PointTypeTrusted?Validation
elementResizeDetectorMaker(options) (src/element-resize-detector.js)Factory / configurationYes (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 + callbackYes (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.removeAllListenersTeardown APIYesElement state existence checked before teardown
options.idHandler / options.reporter / options.batchProcessorInjected strategy objects (callable hooks)Yes — but they are arbitrary caller-supplied code executed inside the libraryNone. 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 engineYesUsed as numbers in CSS pixel strings; no string interpolation from user content
Native resize / scroll / animationstart events on injected nodesBrowser event streamYesHandlers 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 TypeSourceDestinationProtection
Element geometry (offsetWidth, offsetHeight, computed position/top/right/bottom/left)Browser layout engine, via getComputedStyle in scroll.js:storeStartSize and object.jsIn-memory element state (state-handler.js) and interpolated into generated CSS pixel stringsNumeric values only; never persisted or transmitted
Detector element IDsid-generator.js (monotonic counter)Element state and injected <style> element idSequential integers; not derived from and not correlatable to customer data
DOM element referencesCaller (react-sizeme → AdjustableDash.tsx)reporter.warn/reporter.log → browser console when style conflicts or debug mode are activeConsole-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

ActionLocationGuard
Create and append a nested browsing context (<object type="text/html" data="about:blank">) into a caller elementsrc/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 windowsrc/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.headsrc/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 elementssrc/detection-strategy/scroll.js:getScrollbarSizes, :storeStartSize, :initListeners; src/detection-strategy/object.js:injectObjectbuildCssTextString joins hardcoded rule literals and numeric pixel values; no caller strings interpolated
Mutate the host element's inline position stylessrc/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 timerssrc/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 listenerssrc/element-resize-detector.js:uninstall → listener-handler.js:removeAllListeners, strategy uninstallElement 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.