fix: harden macOS usage cost submenu recursion guard (#25341) (thanks @yingchunbai)

This commit is contained in:
Peter Steinberger
2026-02-24 13:48:37 +00:00
parent 96b21f4823
commit 7c99a733a9
3 changed files with 50 additions and 0 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
- Control UI/Chat images: harden image-open clicks against reverse tabnabbing by using opener isolation (`noopener,noreferrer` plus `window.opener = null`). (#18685) Thanks @Mariana-Codebase.
- Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting.
- Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting.

View File

@@ -446,6 +446,8 @@ extension MenuSessionsInjector {
private func buildUsageOverflowMenu(rows: [UsageRow], width: CGFloat) -> NSMenu {
let menu = NSMenu()
// Keep submenu delegate nil: reusing the status-menu delegate here causes
// recursive reinjection whenever this submenu is opened.
for row in rows {
let item = NSMenuItem()
item.tag = self.tag
@@ -1225,6 +1227,12 @@ extension MenuSessionsInjector {
self.usageCacheUpdatedAt = Date()
}
func setTestingCostUsageSummary(_ summary: GatewayCostUsageSummary?, errorText: String? = nil) {
self.cachedCostSummary = summary
self.cachedCostErrorText = errorText
self.costCacheUpdatedAt = Date()
}
func injectForTesting(into menu: NSMenu) {
self.inject(into: menu)
}

View File

@@ -93,4 +93,45 @@ struct MenuSessionsInjectorTests {
#expect(menu.items.contains { $0.tag == 9_415_557 })
#expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem })
}
@Test func costUsageSubmenuDoesNotUseInjectorDelegate() {
let injector = MenuSessionsInjector()
injector.setTestingControlChannelConnected(true)
let summary = GatewayCostUsageSummary(
updatedAt: Date().timeIntervalSince1970 * 1000,
days: 1,
daily: [
GatewayCostUsageDay(
date: "2026-02-24",
input: 10,
output: 20,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 30,
totalCost: 0.12,
missingCostEntries: 0),
],
totals: GatewayCostUsageTotals(
input: 10,
output: 20,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 30,
totalCost: 0.12,
missingCostEntries: 0))
injector.setTestingCostUsageSummary(summary, errorText: nil)
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: ""))
injector.injectForTesting(into: menu)
let usageCostItem = menu.items.first { $0.title == "Usage cost (30 days)" }
#expect(usageCostItem != nil)
#expect(usageCostItem?.submenu != nil)
#expect(usageCostItem?.submenu?.delegate == nil)
}
}