Skip to content

fix: replace eval-based JS interop with dedicated module (fixes #216)#810

Merged
PureWeen merged 1 commit intomainfrom
fix/replace-eval-js-interop-216
Apr 30, 2026
Merged

fix: replace eval-based JS interop with dedicated module (fixes #216)#810
PureWeen merged 1 commit intomainfrom
fix/replace-eval-js-interop-216

Conversation

@github-actions
Copy link
Copy Markdown
Contributor

Summary

Replaces ~46 instances of JS.InvokeVoidAsync("eval", ...) across 7 Razor files with named functions in a dedicated polypilot-interop.js module. This eliminates XSS risks from string interpolation in eval calls, removes prompt injection surface, and improves maintainability.

Changes

New file: wwwroot/js/polypilot-interop.js

A ~660-line JS module containing all named interop functions, organized by component:

  • DOM Helpers: focusAndSelect, blurActiveElement
  • Ref Management: Pre-defined ref setters (__setNavRef, __setDashRef, __setSidebarRef, __ppSetRef)
  • MainLayout: setDataTheme, setDataPlatform, setAppFontSize, startSidebarResize
  • Settings: clearSettingsSearchInput, scrollToSettingsCategory, wireSettingsSearch, setupCategoryIntersectionObserver
  • Dashboard: ensureDashboardKeyHandlers (230-line keyboard handler), ensureTextareaAutoResize, ensureLoadMoreObserver, captureDrafts, scrollMessagesToBottom, saveDraftsAndCursor, setInputValueAndCursor
  • SessionSidebar: wireSessionNameInputEnter, clearSidebarRef, invokeDashboardCollapseToGrid
  • ExpandedSessionView Popups: showPopup, showAgentsPopup, showPromptsPopup, clearPromptRef
  • DiffView: scrollAndFocusCommentBox

Modified Razor files (7 files, -669 lines of inline JS)

  • MainLayout.razor — 8 eval calls replaced
  • Settings.razor — 10 eval calls replaced
  • Dashboard.razor — 13 eval calls replaced
  • ExpandedSessionView.razor — 8 eval calls replaced (3 for prompts popup consolidated into 1)
  • SessionSidebar.razor — 6 eval calls replaced
  • DiffView.razor — 1 eval call replaced

Updated tests

  • PopupThemeTests.cs — Updated to search JS module for popup CSS classes
  • PromptManagementPopupTests.cs — Updated to search JS module for popup UI strings
  • ChatExperienceSafetyTests.cs — Updated draft-restore test to check JS module

New integration test

  • JsInteropModuleTests.cs — Verifies all 28 named functions are registered on window, tests setDataTheme and setAppFontSize work correctly end-to-end

Security improvement

  • Before: Dynamic string interpolation into eval() — any unescaped user input could execute arbitrary JS
  • After: Named functions with typed parameters — Blazor handles JSON serialization of arguments, eliminating injection vectors

Test results

Warning

⚠️ Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • 192.0.2.1

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "192.0.2.1"

See Network Configuration for more information.

Generated by Agent Fix for issue #216 · ● 77.1M ·

…s module

Replace ~46 instances of JS.InvokeVoidAsync("eval", ...) across 7 Razor
files with named functions in a dedicated JS module. This eliminates XSS
risks from string interpolation in eval calls, removes prompt injection
surface, and improves maintainability.

Changes:
- Create wwwroot/js/polypilot-interop.js with all named JS functions
- Add script tag to index.html (loads before Blazor)
- Replace all eval calls in MainLayout, Settings, Dashboard,
  ExpandedSessionView, SessionSidebar, and DiffView
- Update structural tests to search JS module instead of Razor files
- Add integration test verifying all functions are registered

Fixes #216

Co-authored-by: copilot-agentic-workflow[bot] <224017+copilot-agentic-workflow[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions
Copy link
Copy Markdown
Contributor Author

Cross-Platform Verification — PR #810

Build Results

Platform Status
Tests (macOS) ✅ success
Mac Catalyst build ✅ success
Windows build ✅ success

✅ All platforms verified


Triggered by: verify-build run

@github-actions
Copy link
Copy Markdown
Contributor Author

🧪 Integration Test Report — PR #810

Platform Build Launch DevFlow Smoke
Linux/GTK (xvfb)
Mac Catalyst
Windows ⚠️ ⚠️

✅ All platforms passed

View full run

@github-actions
Copy link
Copy Markdown
Contributor Author

Expert Code Review — PR #810

Design-Level Findings (outside diff hunks)

🟢 MINOR — EscapeForJs survives as dead code in wrong context (1/3 reviewers)
ExpandedSessionView.razor:378 still uses EscapeForJs for an HTML attribute value:

<span data-trigger="log-`@EscapeForJs`(Session.SessionId ?? "")" ...>

EscapeForJs performs JS string escaping (\\\, '\'), which is the wrong escaping for an HTML attribute. For GUIDs (the only SessionId values), it's a no-op, so no bug today. Once no callers remain, EscapeForJs can be removed. Low priority.

🟢 MINOR — JSON.stringify used for CSS attribute selectors instead of CSS.escape (1/3 reviewers)
showAgentsPopup and showPromptsPopup use JSON.stringify(sessionName) to build a querySelector attribute selector, while showPopup correctly uses CSS.escape(triggerAttr). JSON and CSS string escaping differ for control characters (e.g., tab → \t means different things). In practice, session names don't contain control characters, so this is not exploitable today. Pre-existing pattern from the old eval code.

🟢 MINOR — InteropModule_NoEvalInRazorComponents test doesn't verify its stated goal (1/3 reviewers)
The test claims to verify absence of eval calls but only checks that focusAndSelect is a function — already covered by the more thorough AllCoreFunctionsRegistered test. A structural scan of *.razor files for InvokeVoidAsync("eval" would provide real regression protection.

i️ Discarded findingsetupCategoryIntersectionObserver observer leak was flagged by 1/3 reviewers. After investigation: the old code had the identical pattern. The Settings component is disposed on navigation, removing the observed elements from DOM, which allows the observer to be GC'd. Not a real leak.

Warning

⚠️ Firewall blocked 2 domains

The following domains were blocked by the firewall during workflow execution:

  • api.nuget.org
  • dc.services.visualstudio.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "api.nuget.org"
    - "dc.services.visualstudio.com"

See Network Configuration for more information.

Generated by Expert Code Review · ● 94.2M ·

Copy link
Copy Markdown
Contributor Author

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — JS Interop Module Migration

Overall: Well-executed security improvement. Eliminating ~46 eval calls removes the string-interpolation XSS vector. The old popup code already used safe DOM APIs (createElement/textContent for user data, server-side EscapeHtml() before innerHTML for pre-built HTML), and these protections carry over correctly. No regressions in the security model.

🟡 MODERATE — Integration test missing 2 functions

JsInteropModuleTests.cs, InteropModule_AllCoreFunctionsRegistered function array

removeSettingsContentActiveClass and clearSettingsRef are both called from Settings.razor (dispose path) but are not in the integration test's function list. The test claims to verify "all named functions" but misses these 2. If either is accidentally removed from the JS module, Settings dispose fails silently (fire-and-forget _ =).

Fix: Add both to the functions array:

"removeSettingsContentActiveClass", "clearSettingsRef",

🟢 MINOR — Fragile function boundary detection in PopupThemeTests

PopupThemeTests.cs, Razor_PromptsPopup_UsesCssClasses

The end-of-function boundary uses js.IndexOf("\nwindow.", startIdx + 10) — if a comment or string containing \nwindow. appears inside the function body, methodBody gets truncated prematurely. Consider asserting against the full JS file content (the CSS class names are unique enough) or using brace-depth counting.


🟢 MINOR — Weakened draft-restore safety assertion

ChatExperienceSafetyTests.cs, DraftRestore_Source_PreservesLiveTyping

The old test asserted window.__liveDrafts in both Dashboard.razor and index.html, forming a two-layer safety check (Razor captures → JS restores). Now only the JS module is checked. Consider adding Assert.Contains("captureDrafts", dashboard) to verify Dashboard.razor still calls the JS function, preserving the cross-layer invariant.


No critical findings. Security improvement is sound — Blazor JSON serialization handles parameter escaping, and the existing EscapeHtml()/textContent patterns protect against innerHTML-based XSS in popup rendering.

Warning

⚠️ Firewall blocked 2 domains

The following domains were blocked by the firewall during workflow execution:

  • api.nuget.org
  • dc.services.visualstudio.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "api.nuget.org"
    - "dc.services.visualstudio.com"

See Network Configuration for more information.

Generated by Expert Code Review · ● 94.2M

Comment on lines +22 to +44
var functions = new[]
{
"setDataTheme", "setDataPlatform", "setAppFontSize",
"startSidebarResize", "blurActiveElement",
"clearSettingsSearchInput", "scrollToSettingsCategory",
"wireSettingsSearch", "setupCategoryIntersectionObserver",
"ensureDashboardKeyHandlers", "clearDashRef",
"ensureTextareaAutoResize", "setDashboardScrollTop",
"getDashboardScrollTop", "ensureLoadMoreObserver",
"captureDrafts", "scrollMessagesToBottom",
"focusAndSelect", "saveDraftsAndCursor",
"setInputValueAndCursor", "showPopup",
"showAgentsPopup", "showPromptsPopup",
"wireSessionNameInputEnter", "clearSidebarRef",
"invokeDashboardCollapseToGrid", "scrollAndFocusCommentBox",
"clearPromptRef",
};

var checkExpr = "JSON.stringify([" +
string.Join(",", functions.Select(f => $"typeof window.{f} === 'function'")) +
"])";

var result = await CdpEvalAsync(checkExpr);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MODERATE · 2/2 reviewers

Integration test is missing several functions from its verification list. The functions array here contains 28 entries, but removeSettingsContentActiveClass and clearSettingsRef (both called fire-and-forget from Settings.razor disposal) are absent. The ref-setter functions (__setNavRef, __setDashRef, __setSettingsRef, __setSidebarRef, __ppSetRef) are also missing — their absence from polypilot-interop.js would cause component initialization failures that this test wouldn't catch.

Scenario: A future refactor accidentally removes removeSettingsContentActiveClass from polypilot-interop.js. Settings page disposal silently throws JSException on every navigation away. This test — the only guard — doesn't catch it.

Suggestion: Add the missing functions to the array:

"removeSettingsContentActiveClass", "clearSettingsRef",
"__setNavRef", "__setDashRef", "__setSettingsRef", "__setSidebarRef", "__ppSetRef",

document.body.setAttribute('data-platform', platform);
};

window.setAppFontSize = function (fontSize) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MODERATE — Duplicate setAppFontSize definition (2/3 reviewers + independent verification)

This function is also defined in index.html (line ~401), which loads after polypilot-interop.js. The index.html version silently overwrites this one, making this definition dead code.

Both implementations are functionally identical today, but if either is updated without the other (e.g., adding Math.round() or input validation), the behavior will diverge silently — the index.html version always wins.

Failing scenario: A developer updates this definition thinking it's the canonical one (since it's in the interop module), but the change has no effect because index.html overwrites it at load time.

Fix: Remove the duplicate from index.html (since this module is now the canonical location for interop functions), or remove it from here and add a comment pointing to the index.html definition.

popup.style.left = left + 'px';
popup.innerHTML = headerHtml + contentHtml;
popup.onclick = function (e) { e.stopPropagation(); };
ov.appendChild(popup);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 MINOR — innerHTML sink requires caller discipline (2/3 reviewers)

popup.innerHTML = headerHtml + contentHtml is safe today because all callers pass pre-escaped content (EscapeHtml() on all dynamic values, static strings for headers). However, this is a latent XSS surface — a future caller forgetting EscapeHtml() would create an injection vector.

Consider adding a brief safety comment here:

// SAFETY: callers MUST EscapeHtml() all user-supplied content before passing to this function.
popup.innerHTML = headerHtml + contentHtml;

No code change needed — just documentation to prevent future regressions.

Comment on lines +380 to +382
});
if (active && active.id) result['__focused'] = active.id;
if (active) { result['__selStart'] = active.selectionStart || 0; result['__selEnd'] = active.selectionEnd || 0; }
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Pre-existing bug, now trivially fixable (3/3 reviewers)

Two issues in captureDrafts that existed in the old eval code and are faithfully reproduced here:

  1. selectionStart throws on non-text inputs. If document.activeElement is an <input type="number"> (Dashboard.razor has at least two), Chromium throws InvalidStateError on selectionStart access. The || 0 fallback only handles falsy values—it does not catch exceptions. The exception propagates through Blazor interop as JSException, the C# catch { } swallows it, and all draft data for that render cycle is silently lost. (saveDraftsAndCursor at line 401 correctly gates this behind focused.matches(sel).)

  2. JSON number → Dictionary<string, string> mismatch. selectionStart || 0 produces a JS integer. JSON.stringify emits {"__selStart":0}. The C# caller deserializes via Deserialize<Dictionary<string, string>> which throws JsonException on JSON integers (strict by default in System.Text.Json). Same silent-discard via the outer catch { }.

Neither is a regression from this PR. But since the code is now centralized, it's the ideal time to fix:

if (active && active.id) result['__focused'] = active.id;
try {
    if (active && typeof active.selectionStart === 'number') {
        result['__selStart'] = String(active.selectionStart);
        result['__selEnd']   = String(active.selectionEnd ?? 0);
    }
} catch (_) { /* non-text input type */ }

Comment on lines +24 to +41
"setDataTheme", "setDataPlatform", "setAppFontSize",
"startSidebarResize", "blurActiveElement",
"clearSettingsSearchInput", "scrollToSettingsCategory",
"wireSettingsSearch", "setupCategoryIntersectionObserver",
"ensureDashboardKeyHandlers", "clearDashRef",
"ensureTextareaAutoResize", "setDashboardScrollTop",
"getDashboardScrollTop", "ensureLoadMoreObserver",
"captureDrafts", "scrollMessagesToBottom",
"focusAndSelect", "saveDraftsAndCursor",
"setInputValueAndCursor", "showPopup",
"showAgentsPopup", "showPromptsPopup",
"wireSessionNameInputEnter", "clearSidebarRef",
"invokeDashboardCollapseToGrid", "scrollAndFocusCommentBox",
"clearPromptRef",
};

var checkExpr = "JSON.stringify([" +
string.Join(",", functions.Select(f => $"typeof window.{f} === 'function'")) +
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Minor: Two functions missing from completeness check (1/3 reviewers, objectively verified)

The module defines removeSettingsContentActiveClass (line 123) and clearSettingsRef (line 46) — both called from Settings.razor's Dispose — but they're not in this list. Consider adding them for full coverage:

"clearSettingsRef",
"removeSettingsContentActiveClass",

@github-actions
Copy link
Copy Markdown
Contributor Author

Review-Fix Loop — Round 1 of 3

Findings Addressed: 6 of 8

# Severity Finding Status
1 🟡 MODERATE Integration test missing 7 functions from verification list ✅ Fixed
2 🟡 MODERATE Duplicate setAppFontSize in index.html overwrites module ✅ Fixed
3 🟡 MODERATE captureDrafts selectionStart throws on non-text inputs + JSON type mismatch ✅ Fixed
4 🟢 MINOR innerHTML sink needs safety comment ✅ Fixed
5 🟢 MINOR JSON.stringifyCSS.escape for CSS attribute selectors ✅ Fixed
6 🟢 MINOR NoEvalInRazorComponents test doesn't verify its stated goal ✅ Fixed
7 🟢 MINOR EscapeForJs survives as dead code ⏭️ Skipped — no-op for GUIDs, low priority
8 i️ Discarded Observer leak in setupCategoryIntersectionObserver ⏭️ Discarded by review panel

Test Results

  • ✅ 3660 passed
  • ⚠️ 1 pre-existing failure (ScheduledTaskTests.GetNextRunTimeUtc_Daily_AlreadyRanToday_ReturnsNextDay — time-zone edge case near UTC midnight, unrelated to this PR)

Next Steps

  • Expert review round 2 dispatched ✅
  • Cross-platform build verification dispatched ✅

Warning

⚠️ Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • 192.0.2.1

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "192.0.2.1"

See Network Configuration for more information.

Generated by Review & Fix · ● 14.1M ·

@github-actions
Copy link
Copy Markdown
Contributor Author

Cross-Platform Verification — PR #810

Build Results

Platform Status
Tests (macOS) ✅ success
Mac Catalyst build ✅ success
Windows build ✅ success

✅ All platforms verified

Previous Review History

Found 4 automated review(s) on this PR. Build verification validates that all review-driven fixes compile and pass tests across platforms.


Triggered by: verify-build run

@PureWeen PureWeen merged commit d4fe92e into main Apr 30, 2026
@PureWeen PureWeen deleted the fix/replace-eval-js-interop-216 branch April 30, 2026 15:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replace eval-based JS interop with dedicated JS module

1 participant