diff --git a/e2e/tests/task-list-basic/add-subtask-with-detail-panel-open.spec.ts b/e2e/tests/task-list-basic/add-subtask-with-detail-panel-open.spec.ts index 73e86b1810..6fbb3d1ff2 100644 --- a/e2e/tests/task-list-basic/add-subtask-with-detail-panel-open.spec.ts +++ b/e2e/tests/task-list-basic/add-subtask-with-detail-panel-open.spec.ts @@ -66,18 +66,28 @@ test.describe('Add subtask with detail panel open', () => { }) => { const parent = await addParentAndOpenPanel(page, workViewPage, taskPage); + // Let the panel's deferred open-time auto-focus land first, otherwise it can + // fire *after* we move focus to the row below and silently steal it back. + await expect + .poll(async () => + page.evaluate(() => !!document.activeElement?.closest('task-detail-panel')), + ) + .toBe(true); + // Put focus back on the main-list task row (as if the user clicked or // arrow-navigated it with the panel open) — the path that goes through the // global shortcut handler and previously left the new subtask unfocused. - await parent.focus(); - await expect - .poll(async () => - page.evaluate(() => { + // Re-apply focus on each retry: under load the row may not accept focus on + // the first try, and .poll() alone would never re-focus it. + await expect(async () => { + await parent.focus(); + expect( + await page.evaluate(() => { const a = document.activeElement as HTMLElement | null; return a?.tagName?.toLowerCase() === 'task' && !a.closest('task-detail-panel'); }), - ) - .toBe(true); + ).toBe(true); + }).toPass(); await page.keyboard.press('a'); diff --git a/electron/electronAPI.d.ts b/electron/electronAPI.d.ts index 90fcd3d4c7..1e98306626 100644 --- a/electron/electronAPI.d.ts +++ b/electron/electronAPI.d.ts @@ -95,13 +95,11 @@ export interface ElectronAPI { /** * Open the native image picker, copy the chosen file into the main-owned * cache, and return an opaque id. The renderer never holds the absolute - * path. Pass `replacesId` to garbage-collect the previous cached image - * the renderer is about to overwrite in its config. Returns null when the - * user cancels or the picked file fails validation. + * path. Returns null when the user cancels and a safe Error when the picked + * file fails validation/import. Old cached images are not deleted here, + * because the surrounding config save may still fail or be cancelled. */ - imagePickAndImport(args?: { - replacesId?: string; - }): Promise<{ id: string; mimeType: string } | null | Error>; + imagePickAndImport(): Promise<{ id: string; mimeType: string } | null | Error>; /** * Resolve a cached image id to a `data:` URL the renderer can use as a diff --git a/electron/local-file-sync.test.cjs b/electron/local-file-sync.test.cjs index 231d92819a..ce37f7a1f4 100644 --- a/electron/local-file-sync.test.cjs +++ b/electron/local-file-sync.test.cjs @@ -124,6 +124,40 @@ test('FILE_SYNC_SAVE rejects an absolute renderer-supplied relativePath', async assert.ok(result instanceof Error); }); +test('FILE_SYNC_SAVE rejects the sync root and does not create a sibling temp file', async () => { + await configureSyncFolder(externalDir); + const siblingTemp = `${externalDir}.tmp`; + const result = await handlers['FILE_SYNC_SAVE']( + {}, + { relativePath: '', dataStr: 'x', localRev: null }, + ); + assert.ok(result instanceof Error); + assert.equal(fs.existsSync(siblingTemp), false, 'must not write outside sync root'); +}); + +test('FILE_SYNC_SAVE does not write through a predictable .tmp symlink', async () => { + await configureSyncFolder(externalDir); + const outside = path.join(userDataDir, 'outside-target'); + fs.writeFileSync(outside, 'keep'); + try { + fs.symlinkSync(outside, path.join(externalDir, 'main.json.tmp'), 'file'); + } catch (e) { + if (process.platform === 'win32') { + return; + } + throw e; + } + + const result = await handlers['FILE_SYNC_SAVE']( + {}, + { relativePath: 'main.json', dataStr: '{"ok":1}', localRev: null }, + ); + + assert.ok(!(result instanceof Error), 'normal save should still work'); + assert.equal(fs.readFileSync(outside, 'utf8'), 'keep'); + assert.equal(fs.readFileSync(path.join(externalDir, 'main.json'), 'utf8'), '{"ok":1}'); +}); + test('PICK_DIRECTORY rejects a folder inside userData', async () => { // A folder equal to (or inside) userData is rejected at pick time so the // user never ends up with a "configured" folder that resolveSyncPath then @@ -307,7 +341,7 @@ test('IMAGE_PICK_AND_IMPORT returns a safe Error when validation fails (no path assert.equal(result.stack, undefined, 'stack stripped, no main-bundle paths'); }); -test('IMAGE_PICK_AND_IMPORT garbage-collects the prior cached image on replace', async () => { +test('IMAGE_PICK_AND_IMPORT does not delete the prior cached image during replace', async () => { const oldPicked = path.join(externalDir, 'old.png'); fs.writeFileSync(oldPicked, 'oldbytes'); nextDialogResult = { canceled: false, filePaths: [oldPicked] }; @@ -319,13 +353,14 @@ test('IMAGE_PICK_AND_IMPORT garbage-collects the prior cached image on replace', const newPicked = path.join(externalDir, 'new.png'); fs.writeFileSync(newPicked, 'newbytes'); nextDialogResult = { canceled: false, filePaths: [newPicked] }; - const newImport = await handlers['IMAGE_PICK_AND_IMPORT']( - {}, - { replacesId: oldImport.id }, - ); + const newImport = await handlers['IMAGE_PICK_AND_IMPORT']({}); assert.ok(newImport); assert.notEqual(newImport.id, oldImport.id); - assert.equal(fs.existsSync(oldCachePath), false, 'old file should be removed'); + assert.equal( + fs.existsSync(oldCachePath), + true, + 'old file must remain until config persistence makes it unreachable', + ); }); test('IMAGE_CACHE_GET_DATA_URL returns null for unknown ids', async () => { diff --git a/electron/local-file-sync.ts b/electron/local-file-sync.ts index 8995f88b2c..5d8f3137af 100644 --- a/electron/local-file-sync.ts +++ b/electron/local-file-sync.ts @@ -1,5 +1,6 @@ import { IPC } from './shared-with-frontend/ipc-events.const'; import { SimpleStoreKey } from './shared-with-frontend/simple-store.const'; +import { randomBytes } from 'crypto'; import { readdirSync, readFileSync, @@ -9,13 +10,14 @@ import { writeFileSync, unlinkSync, } from 'fs'; +import * as path from 'path'; import { error, log } from 'electron-log/main'; import { app, dialog, ipcMain } from 'electron'; import { getWin } from './main-window'; -import { resolveSyncPath } from './sync-path-resolver'; +import { resolveSyncPath, type ResolvedSyncPath } from './sync-path-resolver'; import { loadSimpleStoreAll, saveSimpleStore } from './simple-store'; import { assertPathOutside } from './file-path-guard'; -import { getImageDataUrl, importImage, removeCachedImage } from './image-cache'; +import { getImageDataUrl, importImage } from './image-cache'; // SECURITY: file-sync must never read/write/list inside the app's private dir, // which holds settings/grants/db — touching it is a privilege-escalation @@ -68,12 +70,55 @@ const setSyncFolderPath = async (rawPath: string): Promise => { // so the five handlers below don't repeat the same incantation. Returns the // absolute path on success; throws PathNotAllowedError on rejection (the // existing handler try/catch funnels both into a safe IPC error). -const _resolveRelative = async (relativePath: string | undefined): Promise => +const _resolveRelative = async ( + relativePath: string | undefined, +): Promise => resolveSyncPath( (await getSyncFolderPath()) ?? undefined, relativePath ?? '', getAppPrivateDir(), - ).absolutePath; + ); + +const _pathNotAllowed = (): Error => { + const e = new Error('Path not allowed for the sync folder'); + e.name = 'PathNotAllowedError'; + delete e.stack; + return e; +}; + +const _isEnoent = (e: unknown): boolean => + typeof e === 'object' && + e !== null && + 'code' in e && + (e as { code?: unknown }).code === 'ENOENT'; + +const _assertSaveTargetIsFilePath = (resolved: ResolvedSyncPath): void => { + if (resolved.isRoot) { + throw _pathNotAllowed(); + } + try { + if (statSync(resolved.absolutePath).isDirectory()) { + throw _pathNotAllowed(); + } + } catch (e) { + if (!_isEnoent(e)) { + throw e; + } + } +}; + +const _resolveTempPathForSave = (resolved: ResolvedSyncPath): string => { + const tempName = [ + `.${path.basename(resolved.absolutePath)}`, + process.pid, + Date.now(), + randomBytes(8).toString('hex'), + 'tmp', + ].join('.'); + const candidate = path.join(path.dirname(resolved.absolutePath), tempName); + const tempRelative = path.relative(resolved.root, candidate); + return resolveSyncPath(resolved.root, tempRelative, getAppPrivateDir()).absolutePath; +}; export const initLocalFileSyncAdapter = (): void => { ipcMain.handle( @@ -90,8 +135,11 @@ export const initLocalFileSyncAdapter = (): void => { localRev: string | null; }, ): Promise => { + let tempPath: string | null = null; try { - const filePath = await _resolveRelative(relativePath); + const resolved = await _resolveRelative(relativePath); + _assertSaveTargetIsFilePath(resolved); + const filePath = resolved.absolutePath; log(IPC.FILE_SYNC_SAVE, { dataLength: dataStr.length, hasData: dataStr.length > 0, @@ -100,12 +148,20 @@ export const initLocalFileSyncAdapter = (): void => { // Atomic write: write to temp file first, then rename. // renameSync is atomic on ext4/APFS/NTFS, so a crash mid-write // won't corrupt the original file. - const tempPath = filePath + '.tmp'; - writeFileSync(tempPath, dataStr); + tempPath = _resolveTempPathForSave(resolved); + writeFileSync(tempPath, dataStr, { encoding: 'utf8', flag: 'wx' }); renameSync(tempPath, filePath); + tempPath = null; return getRev(filePath); } catch (e) { + if (tempPath) { + try { + unlinkSync(tempPath); + } catch { + // Best-effort cleanup; the safe IPC error below is the important part. + } + } error('Local file sync save failed', getSafeErrorMeta(e)); return createSafeIpcError(IPC.FILE_SYNC_SAVE, e); } @@ -125,7 +181,7 @@ export const initLocalFileSyncAdapter = (): void => { }, ): Promise<{ rev: string; dataStr: string | undefined } | Error> => { try { - const filePath = await _resolveRelative(relativePath); + const filePath = (await _resolveRelative(relativePath)).absolutePath; log(IPC.FILE_SYNC_LOAD, { hasLocalRev: !!localRev, }); @@ -155,7 +211,7 @@ export const initLocalFileSyncAdapter = (): void => { }, ): Promise => { try { - const filePath = await _resolveRelative(relativePath); + const filePath = (await _resolveRelative(relativePath)).absolutePath; log(IPC.FILE_SYNC_REMOVE); unlinkSync(filePath); return; @@ -179,7 +235,7 @@ export const initLocalFileSyncAdapter = (): void => { try { // Default to the sync root itself (relativePath = '') so the legacy // "is the configured sync folder reachable?" check still works. - const dirPath = await _resolveRelative(relativePath); + const dirPath = (await _resolveRelative(relativePath)).absolutePath; const dirEntries = readdirSync(dirPath); log(IPC.CHECK_DIR_EXISTS, { dirEntryCount: dirEntries.length, @@ -208,7 +264,7 @@ export const initLocalFileSyncAdapter = (): void => { }, ): Promise => { try { - const dirPath = await _resolveRelative(relativePath); + const dirPath = (await _resolveRelative(relativePath)).absolutePath; return readdirSync(dirPath); } catch (e) { error('Local file sync list files failed', getSafeErrorMeta(e)); @@ -256,10 +312,7 @@ export const initLocalFileSyncAdapter = (): void => { ipcMain.handle( IPC.IMAGE_PICK_AND_IMPORT, - async ( - _, - args?: { replacesId?: string }, - ): Promise<{ id: string; mimeType: string } | null | Error> => { + async (): Promise<{ id: string; mimeType: string } | null | Error> => { // SECURITY: the dialog + import are atomic and run together in main. // The renderer never holds the absolute path — it cannot trigger an // image read without a user clicking through the native picker. @@ -294,13 +347,6 @@ export const initLocalFileSyncAdapter = (): void => { // (from a raw `e.stack`) are not leaked. throw new Error('Selected image could not be imported'); } - if (typeof args?.replacesId === 'string' && args.replacesId) { - // GC: drop the file the renderer is about to overwrite in its config. - // Renderer-supplied id is opaque; worst case is removing a cached - // image that wasn't actually orphaned, which the user can recover by - // re-picking. - await removeCachedImage(args.replacesId); - } return imported; } catch (e) { error('Image pick-and-import failed', getSafeErrorMeta(e)); diff --git a/electron/preload.ts b/electron/preload.ts index 71b93d9bd2..f5b3ca4245 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -90,8 +90,8 @@ const ea: ElectronAPI = { filters?: { name: string; extensions: string[] }[]; }) => _invoke('SHOW_OPEN_DIALOG', options) as Promise, - imagePickAndImport: (args) => - _invoke('IMAGE_PICK_AND_IMPORT', args) as Promise< + imagePickAndImport: () => + _invoke('IMAGE_PICK_AND_IMPORT') as Promise< | { id: string; mimeType: string; diff --git a/packages/sync-providers/src/file-based/local-file/local-file-sync-electron.ts b/packages/sync-providers/src/file-based/local-file/local-file-sync-electron.ts index 0a6aea6f45..40cc66c795 100644 --- a/packages/sync-providers/src/file-based/local-file/local-file-sync-electron.ts +++ b/packages/sync-providers/src/file-based/local-file/local-file-sync-electron.ts @@ -37,9 +37,8 @@ export class LocalFileSyncElectron extends LocalFileSyncBase { if (!folderPath) { // Migration breadcrumb (#8228): a pre-fix install may still have a // syncFolderPath in the renderer credential store. Log so the dev - // console shows why isReady=false until the user re-picks. A - // user-facing snack is the right shape but i18n infrastructure - // belongs in a UX follow-up, not the security fix. + // console shows why isReady=false until the user re-picks. The host + // app surfaces a sticky user-facing warning from its provider manager. const legacyCfg = await this.privateCfg.load().catch(() => null); if (legacyCfg?.syncFolderPath) { this.logger.critical( diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 433a6faad3..6e7ecc891f 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -174,6 +174,7 @@ export class AppComponent implements OnDestroy, AfterViewInit { readonly _store = inject(Store); private _sectionService = inject(SectionService); private _browserTitleService = inject(BrowserTitleService); + private _hasShownLegacyFileBgSnack = false; readonly T = T; readonly TODAY_TAG_ID = TODAY_TAG.id; readonly isShowMobileButtonNav = this.layoutService.isShowMobileBottomNav; @@ -283,6 +284,18 @@ export class AppComponent implements OnDestroy, AfterViewInit { effect(() => { const bgImage = this._globalThemeService.backgroundImg(); const currentRequestId = ++bgResolveRequestId; + if ( + typeof bgImage === 'string' && + bgImage.startsWith('file://') && + !this._hasShownLegacyFileBgSnack + ) { + this._hasShownLegacyFileBgSnack = true; + this._snackService.open({ + msg: T.F.PROJECT.FORM_THEME.S_BACKGROUND_IMAGE_RESELECT_REQUIRED, + type: 'WARNING', + config: { duration: 0 }, + }); + } void resolveBgImageToDataUrl(bgImage).then((resolved) => { // Ignore stale resolutions when the source changed mid-read. if (currentRequestId === bgResolveRequestId) { diff --git a/src/app/core/theme/resolve-bg-image-to-data-url.util.ts b/src/app/core/theme/resolve-bg-image-to-data-url.util.ts index d3406e378b..c560165ef7 100644 --- a/src/app/core/theme/resolve-bg-image-to-data-url.util.ts +++ b/src/app/core/theme/resolve-bg-image-to-data-url.util.ts @@ -46,9 +46,8 @@ export const resolveBgImageToDataUrl = async ( if (bgImage.startsWith('file://')) { // Legacy shape — picker now produces `image:`. No safe IPC to read // an arbitrary renderer-supplied path remains; user must re-pick. - // TODO(#8228 follow-up): one-time snack to nudge re-pick. Translation - // infrastructure is the only blocker; for now we log so devs notice. - // Static message only — never log the URL (it's user content). + // Static message only — never log the URL (it's user content). AppComponent + // shows the user-facing re-pick snack once per session. if (!_hasWarnedAboutLegacyFileUrl) { _hasWarnedAboutLegacyFileUrl = true; Log.warn( diff --git a/src/app/features/issue/providers/jira/jira-api.service.spec.ts b/src/app/features/issue/providers/jira/jira-api.service.spec.ts index 478673b5d6..6e2709db33 100644 --- a/src/app/features/issue/providers/jira/jira-api.service.spec.ts +++ b/src/app/features/issue/providers/jira/jira-api.service.spec.ts @@ -342,6 +342,11 @@ describe('JiraApiService', () => { beforeEach(() => { service = setupService(new Subject()); fetchSpy = spyOn(window, 'fetch'); + // findAutoImportIssues$ → _sendRequest$ bails out with "Jira Offline" when + // !isOnline(). These tests assert pagination, not connectivity, so pin the + // online state instead of depending on the runner's real navigator.onLine + // (false under headless/offline CI). Jasmine restores the spy per test. + spyOnProperty(navigator, 'onLine').and.returnValue(true); }); it('fetches all Jira Cloud search/jql pages via nextPageToken', (done) => { diff --git a/src/app/features/note/note/note.component.html b/src/app/features/note/note/note.component.html index 91afb00de6..e481780f36 100644 --- a/src/app/features/note/note/note.component.html +++ b/src/app/features/note/note/note.component.html @@ -6,8 +6,8 @@ > @if (note.imgUrl) { diff --git a/src/app/features/note/note/note.component.ts b/src/app/features/note/note/note.component.ts index 2af3f2fb4b..211db76b9d 100644 --- a/src/app/features/note/note/note.component.ts +++ b/src/app/features/note/note/note.component.ts @@ -38,6 +38,7 @@ import { DEFAULT_PROJECT_COLOR } from '../../work-context/work-context.const'; import { DEFAULT_PROJECT_ICON } from '../../project/project.const'; import { ClipboardImageService } from '../../../core/clipboard-image/clipboard-image.service'; import { RenderLinksPipe } from '../../../ui/pipes/render-links.pipe'; +import { isPathSafeToOpen } from '../../../../../electron/shared-with-frontend/is-external-url-allowed'; @Component({ selector: 'note', @@ -69,10 +70,16 @@ export class NoteComponent implements OnChanges { note!: Note; + // The src auto-loads on render (no click), so a synced remote file:// / + // UNC imgUrl would silently leak the user's NTLM hash. Only a safe URL reaches + // the [src]/[enlargeImg] bindings. See GHSA-hr87-735w-hfq3. + safeImgUrl?: string; + // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @Input('note') set noteSet(v: Note) { this.note = v; + this.safeImgUrl = isPathSafeToOpen(v?.imgUrl) ? v.imgUrl : undefined; this._note$.next(v); this._updateNoteTxt(); } diff --git a/src/app/features/schedule/schedule/schedule-locale-ng0701.spec.ts b/src/app/features/schedule/schedule/schedule-locale-ng0701.spec.ts index 87f066456d..ca606a03e3 100644 --- a/src/app/features/schedule/schedule/schedule-locale-ng0701.spec.ts +++ b/src/app/features/schedule/schedule/schedule-locale-ng0701.spec.ts @@ -8,6 +8,7 @@ import { DateAdapter } from '@angular/material/core'; import { MatDialog } from '@angular/material/dialog'; import { ScheduleComponent } from './schedule.component'; +import { CalendarEventActionsService } from '../../calendar-integration/calendar-event-actions.service'; import { TaskService } from '../../tasks/task.service'; import { LayoutService } from '../../../core-ui/layout/layout.service'; import { ScheduleService } from '../schedule.service'; @@ -109,6 +110,25 @@ describe('issue #7383 — NG0701 race on /schedule', () => { { provide: LayoutService, useValue: mockLayoutService }, { provide: ScheduleService, useValue: mockScheduleService }, { provide: MatDialog, useValue: jasmine.createSpyObj('MatDialog', ['open']) }, + { + // ScheduleComponent's children (schedule-week/schedule-event) eagerly + // inject the root-provided CalendarEventActionsService, whose real DI + // chain pulls in IssueService → SnackService → LOCAL_ACTIONS (needs NgRx + // Actions, which provideMockStore does not supply). Without a mock the + // deferred CD pass after the test hits NG0201 and the async error kills + // the whole Karma session. Mock it to keep the injector self-contained. + provide: CalendarEventActionsService, + useValue: jasmine.createSpyObj('CalendarEventActionsService', [ + 'hasEventUrl', + 'isPluginEvent', + 'canMoveEvent', + 'openEventLink', + 'reschedule', + 'createAsTask', + 'hideForever', + 'deleteEvent', + ]), + }, { provide: GlobalTrackingIntervalService, useValue: mockGlobalTrackingIntervalService, diff --git a/src/app/features/work-context/work-context.const.ts b/src/app/features/work-context/work-context.const.ts index 450a5da64c..23d03804ea 100644 --- a/src/app/features/work-context/work-context.const.ts +++ b/src/app/features/work-context/work-context.const.ts @@ -175,7 +175,7 @@ export const WORK_CONTEXT_THEME_CONFIG_FORM_CONFIG: ConfigFormSection[] | null = null; @@ -72,6 +75,7 @@ export class SyncProviderManager { // Emits whenever provider config is updated via setProviderConfig() private _providerConfigChanged$ = new Subject(); + private _hasShownLocalFileReselectSnack = false; /** * Observable for whether the sync provider is enabled and ready @@ -249,6 +253,7 @@ export class SyncProviderManager { // Re-check readiness const ready = await provider.isReady(); this._isProviderReady$.next(ready); + this._maybeShowLocalFileReselectSnack(providerId, ready, config); } } @@ -325,6 +330,7 @@ export class SyncProviderManager { } this._isProviderReady$.next(ready); this._currentProviderPrivateCfg$.next({ providerId, privateCfg }); + this._maybeShowLocalFileReselectSnack(providerId, ready, privateCfg); SyncLog.normal(`SyncProviderManager: Active provider set to ${providerId}`); }) .catch((e) => { @@ -334,4 +340,29 @@ export class SyncProviderManager { } }); } + + private _maybeShowLocalFileReselectSnack( + providerId: SyncProviderId, + isReady: boolean, + privateCfg: unknown, + ): void { + if ( + this._hasShownLocalFileReselectSnack || + isReady || + providerId !== SyncProviderId.LocalFile + ) { + return; + } + const legacyPath = (privateCfg as { syncFolderPath?: unknown } | null) + ?.syncFolderPath; + if (typeof legacyPath !== 'string' || !legacyPath) { + return; + } + this._hasShownLocalFileReselectSnack = true; + this._snackService.open({ + msg: T.F.SYNC.S.LOCAL_FILE_RESELECT_REQUIRED, + type: 'WARNING', + config: { duration: 0 }, + }); + } } diff --git a/src/app/op-log/testing/integration/sync-local-only-hydration.integration.spec.ts b/src/app/op-log/testing/integration/sync-local-only-hydration.integration.spec.ts index 519fd32145..ba983c894b 100644 --- a/src/app/op-log/testing/integration/sync-local-only-hydration.integration.spec.ts +++ b/src/app/op-log/testing/integration/sync-local-only-hydration.integration.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/naming-convention */ /** * Integration test for the #8077 own-op-replay regression. * diff --git a/src/app/plugins/plugin-cleanup.service.ts b/src/app/plugins/plugin-cleanup.service.ts index a7fcd01aad..d2133c1c02 100644 --- a/src/app/plugins/plugin-cleanup.service.ts +++ b/src/app/plugins/plugin-cleanup.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@angular/core'; -import { cleanupAllPluginIframeUrls } from './util/plugin-iframe.util'; /** * Simplified cleanup service following KISS principles. @@ -34,8 +33,5 @@ export class PluginCleanupService { cleanupAll(): void { // Just clear all references - let Angular manage iframe DOM lifecycle this._pluginIframes.clear(); - - // Clean up all blob URLs to prevent memory leaks - cleanupAllPluginIframeUrls(); } } diff --git a/src/app/plugins/ui/plugin-index/plugin-index.component.html b/src/app/plugins/ui/plugin-index/plugin-index.component.html index 190111d4df..5e59fa2ea9 100644 --- a/src/app/plugins/ui/plugin-index/plugin-index.component.html +++ b/src/app/plugins/ui/plugin-index/plugin-index.component.html @@ -59,16 +59,19 @@ } - @if (iframeSrc() && !error()) { + @if (iframeSrcdoc() && !error()) {
+ Angular forbids binding security-sensitive iframe attributes (throws NG0910). + No allow-same-origin: the iframe runs on an opaque origin so it cannot reach + window.parent.ea. Loaded via srcdoc (not a blob: URL) so it still renders on + packaged file:// builds, where an opaque-origin blob: fetch would be blocked. -->