diff --git a/src/renderer/src/i18n/plugin-chrome-allowlist.test.ts b/src/renderer/src/i18n/plugin-chrome-allowlist.test.ts new file mode 100644 index 000000000000..6cf15db4f8dc --- /dev/null +++ b/src/renderer/src/i18n/plugin-chrome-allowlist.test.ts @@ -0,0 +1,58 @@ +/** + * The plugin-chrome exemption is a list of exact catalog paths, so it can rot in + * two directions: a renamed key leaves a dead entry behind, and a key that moves + * into a component the security tests name would widen the exemption silently. + * These assertions catch both. + */ +import { describe, expect, it } from 'vitest' +import { translatablePluginChromePaths } from '../../../shared/plugins/plugin-translatable-chrome' +import en from './locales/en.json' + +// Kept in sync with the components plugin-language-pack-artifact.test.ts pins as +// security copy: nothing from them may appear in the chrome exemption. +const SECURITY_COPY_COMPONENTS = [ + 'PluginConsentDialog', + 'PluginConsentProvenance', + 'pluginError', + 'PluginKeybindingConsentPreview', + 'PluginMarketplaceListingRow', + 'PluginMarketplacePreviewDialog', + 'PluginMarketplaceSourceDialog', + 'PluginRemoveDialog', + 'PluginRollbackDialog', + 'PluginVmRecipeConsentPreview' +] + +function lookup(key: string): string | undefined { + const value = key + .split('.') + .reduce( + (node, part) => + node && typeof node === 'object' ? (node as Record)[part] : undefined, + en as unknown + ) + return typeof value === 'string' ? value : undefined +} + +describe('translatable plugin chrome', () => { + it.each(translatablePluginChromePaths())('%s still exists in the English catalog', (path) => { + expect(lookup(path)).toBeTypeOf('string') + }) + + it('exempts nothing from a component the security tests pin', () => { + const leaked = translatablePluginChromePaths().filter((path) => + SECURITY_COPY_COMPONENTS.some((component) => + path.startsWith(`auto.components.settings.${component}.`) + ) + ) + + expect(leaked).toEqual([]) + }) + + // Why: `*Failed` copy reports what happened to a trust decision (a reviewed + // source that changed, a rollback target that is gone), so the boundary is the + // whole family rather than a per-message judgement. + it('exempts no failure copy', () => { + expect(translatablePluginChromePaths().filter((path) => path.endsWith('Failed'))).toEqual([]) + }) +}) diff --git a/src/shared/plugins/plugin-language-pack-artifact.test.ts b/src/shared/plugins/plugin-language-pack-artifact.test.ts index c3f9980e7e6e..65020af790a9 100644 --- a/src/shared/plugins/plugin-language-pack-artifact.test.ts +++ b/src/shared/plugins/plugin-language-pack-artifact.test.ts @@ -42,6 +42,9 @@ describe('plugin language-pack artifacts', () => { 'PluginMarketplaceListingRow', 'PluginMarketplacePreviewDialog', 'PluginMarketplaceSourceDialog', + // Destructive confirmations: a rewrite could defuse a remove/rollback warning. + 'PluginRemoveDialog', + 'PluginRollbackDialog', 'PluginVmRecipeConsentPreview' ])('prevents language packs from rewriting %s security copy', (component) => { expect( @@ -57,6 +60,78 @@ describe('plugin language-pack artifacts', () => { ).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') }) }) + it.each([ + ['PluginsSettingsSection', 'title', 'Плагины'], + ['PluginMarketplaceBrowser', 'refresh', 'Обновить'], + ['PluginDevelopmentSection', 'title', 'Разработка'] + ])('lets a language pack translate %s.%s, which asserts nothing', (component, key, value) => { + expect( + parsePluginLanguagePackArtifact( + JSON.stringify({ + auto: { components: { settings: { [component]: { [key]: value } } } } + }) + ).ok + ).toBe(true) + }) + + // Why: `plugins.search.*` needs two protected containers to stay walkable, + // unlike the single-container component paths above. + it('lets a language pack translate nested settings-search chrome', () => { + expect( + parsePluginLanguagePackArtifact( + JSON.stringify({ + auto: { components: { settings: { plugins: { search: { title: 'Плагины' } } } } } + }) + ).ok + ).toBe(true) + }) + + // Why: the chrome exemption must not leak into the copy that carries a claim + // about what plugins may do — that copy is the whole reason the prefix is broad. + it.each([ + ['description', 'Plugins are sandboxed and cannot read your files.'], + ['systemDescription', 'Every plugin here has been reviewed by Orca.'], + ['featureOff', 'Installed plugins keep running while the system is off.'] + ])('still refuses PluginsSettingsSection.%s', (key, value) => { + expect( + parsePluginLanguagePackArtifact( + JSON.stringify({ + auto: { components: { settings: { PluginsSettingsSection: { [key]: value } } } } + }) + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') }) + }) + + it('still refuses the development-plugin permission promise', () => { + expect( + parsePluginLanguagePackArtifact( + JSON.stringify({ + auto: { + components: { + settings: { + PluginDevelopmentSection: { help: 'Dev plugins skip permission review.' } + } + } + } + }) + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') }) + }) + + it('still refuses install failure copy that reports a trust event', () => { + expect( + parsePluginLanguagePackArtifact( + JSON.stringify({ + auto: { + components: { + settings: { PluginMarketplaceBrowser: { installFailed: 'Installed successfully.' } } + } + } + }) + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') }) + }) + it('forges no trust badge: the community→Official swap is refused', () => { expect( parsePluginLanguagePackArtifact( diff --git a/src/shared/plugins/plugin-language-pack-artifact.ts b/src/shared/plugins/plugin-language-pack-artifact.ts index 0e4dd218d95b..917adeef69ef 100644 --- a/src/shared/plugins/plugin-language-pack-artifact.ts +++ b/src/shared/plugins/plugin-language-pack-artifact.ts @@ -1,3 +1,8 @@ +import { + translatablePluginChrome, + translatablePluginChromeContainer +} from './plugin-translatable-chrome' + export const PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES = 20_000 export const PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH = 16 @@ -5,6 +10,8 @@ const DANGEROUS_CATALOG_KEYS = new Set(['__proto__', 'prototype', 'constructor'] // Why: every plugin-facing security surface lives under this namespace, so // protecting the whole subtree keeps a new dialog from silently becoming // plugin-writable the way PluginConsentProvenance did when it was extracted. +// The subtree also holds copy with no security meaning; those exact paths are +// listed in plugin-translatable-chrome.ts and stay translatable. const PROTECTED_TRANSLATION_ROOT = 'auto.components.settings.' const PROTECTED_TRANSLATION_MODULE = /^plugin/i @@ -45,6 +52,9 @@ function protectedTranslation(path: string): boolean { if (!path.startsWith(PROTECTED_TRANSLATION_ROOT)) { return false } + if (translatablePluginChrome(path)) { + return false + } return PROTECTED_TRANSLATION_MODULE.test(path.slice(PROTECTED_TRANSLATION_ROOT.length)) } @@ -88,7 +98,12 @@ export function parsePluginLanguagePackArtifact(raw: string): PluginLanguagePack return { ok: false, error: `catalog key ${key || '(empty)'} is not safe` } } const path = frame.path ? `${frame.path}.${key}` : key - if (protectedTranslation(path)) { + // A protected container stays walkable only when exempt chrome sits below + // it; every leaf inside is still matched against its own full path. + if ( + protectedTranslation(path) && + !(isCatalogObject(value) && translatablePluginChromeContainer(path)) + ) { return { ok: false, error: `catalog cannot replace protected security copy at ${path}` } } if (typeof value === 'string') { diff --git a/src/shared/plugins/plugin-translatable-chrome.ts b/src/shared/plugins/plugin-translatable-chrome.ts new file mode 100644 index 000000000000..19c275659ede --- /dev/null +++ b/src/shared/plugins/plugin-translatable-chrome.ts @@ -0,0 +1,93 @@ +// Why: the protected-translation prefix is deliberately broad so a consent +// surface added tomorrow is covered the day it lands — the lesson from +// PluginConsentProvenance. That breadth also catches copy that carries no +// security meaning at all: section titles, empty states, search affordances, +// and the local development form. A language pack that cannot translate +// "Refresh" leaves the plugins pane half-translated in every locale. +// +// This list is the narrow exception, and it is a list of exact paths rather +// than a pattern on purpose: anything new stays protected until someone +// deliberately adds it here, so the fail-safe survives. +// +// A path belongs here only if rewriting it cannot mislead the person deciding +// whether to trust a plugin. That rules out, and this list therefore omits: +// +// - trust badges and safety status — `PluginMarketplaceListingRow.official`, +// `.blocked`, `PluginSettingsRow.blocked`; +// - promises about what plugins may do — `PluginsSettingsSection.description` +// ("Plugins run on this computer"), `.systemDescription` ("Nothing runs +// until you review and enable it"), `.featureOff` ("stays disabled"), and +// `PluginDevelopmentSection.help` ("Dev plugins still require permission +// review"). Swapping any of these for reassuring text is the attack; +// - failure copy that reports a trust event — `installFailed` ("The reviewed +// source may have changed"). Sibling `*Failed` strings stay protected too, +// so the boundary is a rule rather than a judgement call per message; +// - destructive confirmations — `PluginRemoveDialog`, `PluginRollbackDialog`; +// - every dialog the existing tests already name as security copy. +const TRANSLATABLE_PLUGIN_CHROME = new Set([ + // Plugins pane frame: headings and list states, no claims about behavior. + 'auto.components.settings.PluginsSettingsSection.title', + 'auto.components.settings.PluginsSettingsSection.experimental', + 'auto.components.settings.PluginsSettingsSection.systemLabel', + 'auto.components.settings.PluginsSettingsSection.install', + 'auto.components.settings.PluginsSettingsSection.loading', + 'auto.components.settings.PluginsSettingsSection.empty', + 'auto.components.settings.PluginsSettingsSection.emptyTitle', + 'auto.components.settings.PluginsSettingsSection.noInstalledResults', + 'auto.components.settings.PluginsSettingsSection.noInstalledResultsTitle', + // Marketplace browser chrome: refresh, search, and empty states. + 'auto.components.settings.PluginMarketplaceBrowser.manageSources', + 'auto.components.settings.PluginMarketplaceBrowser.addSource', + 'auto.components.settings.PluginMarketplaceBrowser.refresh', + 'auto.components.settings.PluginMarketplaceBrowser.refreshing', + 'auto.components.settings.PluginMarketplaceBrowser.loading', + 'auto.components.settings.PluginMarketplaceBrowser.tryAgain', + 'auto.components.settings.PluginMarketplaceBrowser.clearSearch', + 'auto.components.settings.PluginMarketplaceBrowser.empty', + 'auto.components.settings.PluginMarketplaceBrowser.emptyTitle', + 'auto.components.settings.PluginMarketplaceBrowser.noInstalled', + 'auto.components.settings.PluginMarketplaceBrowser.noInstalledTitle', + 'auto.components.settings.PluginMarketplaceBrowser.noResults', + 'auto.components.settings.PluginMarketplaceBrowser.noResultsTitle', + 'auto.components.settings.PluginMarketplaceBrowser.noSourcesTitle', + // Local development form: field labels and validation, desktop-only paths. + 'auto.components.settings.PluginDevelopmentSection.title', + 'auto.components.settings.PluginDevelopmentSection.add', + 'auto.components.settings.PluginDevelopmentSection.remove', + 'auto.components.settings.PluginDevelopmentSection.pathLabel', + 'auto.components.settings.PluginDevelopmentSection.pathRequired', + 'auto.components.settings.PluginDevelopmentSection.placeholder', + // Settings-search index entries: they route to a pane, they do not assert. + 'auto.components.settings.plugins.search.title', + 'auto.components.settings.plugins.search.description', + 'auto.components.settings.plugins.search.install', + 'auto.components.settings.plugins.search.permissions', + 'auto.components.settings.plugins.search.logs', + 'auto.components.settings.plugins.search.development' +]) + +/** True when a protected-prefix path is plugin chrome a language pack may translate. */ +export function translatablePluginChrome(path: string): boolean { + return TRANSLATABLE_PLUGIN_CHROME.has(path) +} + +/** + * True when a protected-prefix container holds exempt chrome somewhere below it. + * The walk rejects a protected path as soon as it sees it, so a container has to + * stay walkable for its exempt leaves to be reachable — every leaf inside is + * still checked against its own full path. + */ +export function translatablePluginChromeContainer(path: string): boolean { + const prefix = `${path}.` + for (const exempt of TRANSLATABLE_PLUGIN_CHROME) { + if (exempt.startsWith(prefix)) { + return true + } + } + return false +} + +/** The exempt paths, for tests that check the list against the English catalog. */ +export function translatablePluginChromePaths(): string[] { + return [...TRANSLATABLE_PLUGIN_CHROME] +}