Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/renderer/src/i18n/plugin-chrome-allowlist.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>(
(node, part) =>
node && typeof node === 'object' ? (node as Record<string, unknown>)[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([])
})
})
75 changes: 75 additions & 0 deletions src/shared/plugins/plugin-language-pack-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down
17 changes: 16 additions & 1 deletion src/shared/plugins/plugin-language-pack-artifact.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import {
translatablePluginChrome,
translatablePluginChromeContainer
} from './plugin-translatable-chrome'

export const PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES = 20_000
export const PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH = 16

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

Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -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') {
Expand Down
93 changes: 93 additions & 0 deletions src/shared/plugins/plugin-translatable-chrome.ts
Original file line number Diff line number Diff line change
@@ -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]
}