feat: add secure pac module packages
This commit is contained in:
@@ -7,7 +7,10 @@ export function registerModulesIpc(): void {
|
||||
ipcMain.handle('modules:listRegistry', () => listModules())
|
||||
ipcMain.handle('modules:listAvailable', () => getAvailableModulePackages())
|
||||
ipcMain.handle('modules:pickAndInstallPackage', async () => {
|
||||
const picked = await dialog.showOpenDialog({ properties: ['openDirectory'] })
|
||||
const picked = await dialog.showOpenDialog({
|
||||
properties: ['openFile', 'openDirectory'],
|
||||
filters: [{ name: 'Aurora module packages', extensions: ['pac'] }]
|
||||
})
|
||||
if (picked.canceled || !picked.filePaths[0]) return null
|
||||
return installModulePackage(picked.filePaths[0])
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { mkdtemp, mkdir, readFile, symlink, writeFile } from 'fs/promises'
|
||||
import { promisify } from 'util'
|
||||
import { join, resolve } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -7,9 +9,16 @@ vi.mock('electron', () => ({ app: { getPath: () => '/unused', getAppPath: () =>
|
||||
import { getAvailableModulePackages, getModuleRegistry, installModulePackage, uninstallModulePackage, validateModuleManifest } from './moduleRegistry'
|
||||
|
||||
const roots: string[] = []
|
||||
const execFileAsync = promisify(execFile)
|
||||
async function temp(prefix: string): Promise<string> { const root = await mkdtemp(join(tmpdir(), prefix)); roots.push(root); return root }
|
||||
const manifest = { id: 'sample-app', name: 'Sample', version: '1.0.0', category: 'application', description: 'test', aurora: { core: '2.0.0-alpha.24', moduleApi: '1.0.0' }, dependencies: [], conflicts: [], settings: [] }
|
||||
async function packageDir(value = manifest): Promise<string> { const root = await temp('aurora-package-'); await writeFile(join(root, 'manifest.json'), JSON.stringify(value)); return root }
|
||||
async function pacFile(value = manifest): Promise<string> {
|
||||
const source = await packageDir(value)
|
||||
const output = join(await temp('aurora-pac-'), `${value.id}.pac`)
|
||||
await execFileAsync('zip', ['-qr', output, '.'], { cwd: source })
|
||||
return output
|
||||
}
|
||||
|
||||
afterEach(async () => { const { rm } = await import('fs/promises'); await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) })
|
||||
|
||||
@@ -21,6 +30,27 @@ describe('external module registry', () => {
|
||||
expect((await getAvailableModulePackages()).map((item) => item.manifest.id)).toContain('sample-app')
|
||||
delete process.env.AURORA_MODULE_PATH
|
||||
})
|
||||
it('discovers and installs a compressed .pac package', async () => {
|
||||
const catalog = await temp('aurora-catalog-')
|
||||
const pac = await pacFile()
|
||||
const catalogPac = join(catalog, 'sample-app.pac')
|
||||
await import('fs/promises').then(({ copyFile }) => copyFile(pac, catalogPac))
|
||||
process.env.AURORA_MODULE_PATH = catalog
|
||||
expect((await getAvailableModulePackages()).map((item) => item.manifest.id)).toContain('sample-app')
|
||||
delete process.env.AURORA_MODULE_PATH
|
||||
const userData = await temp('aurora-user-')
|
||||
const installed = await installModulePackage(catalogPac, userData)
|
||||
expect(installed.manifest.id).toBe('sample-app')
|
||||
expect((await getModuleRegistry(userData)).map((item) => item.id)).toEqual(['sample-app'])
|
||||
})
|
||||
it('discovers a .pac distributed beside the AppImage', async () => {
|
||||
const release = await temp('aurora-release-')
|
||||
const pac = await pacFile()
|
||||
await import('fs/promises').then(({ copyFile }) => copyFile(pac, join(release, 'sample-app.pac')))
|
||||
process.env.APPIMAGE = join(release, 'aurora-dockside.AppImage')
|
||||
expect((await getAvailableModulePackages()).map((item) => item.manifest.id)).toContain('sample-app')
|
||||
delete process.env.APPIMAGE
|
||||
})
|
||||
it('installs a valid local package and refreshes after install', async () => {
|
||||
const userData = await temp('aurora-user-'); const source = await packageDir()
|
||||
await installModulePackage(source, userData)
|
||||
@@ -37,6 +67,26 @@ describe('external module registry', () => {
|
||||
const userData = await temp('aurora-user-'); const source = await packageDir(); await mkdir(join(source, 'main')); await symlink('/tmp', join(source, 'main', 'escape'))
|
||||
await expect(installModulePackage(source, userData)).rejects.toThrow(/symbolic links/)
|
||||
})
|
||||
it('rejects symbolic links and traversal paths inside .pac archives', async () => {
|
||||
const userData = await temp('aurora-user-')
|
||||
const linkedSource = await packageDir()
|
||||
await symlink('/tmp', join(linkedSource, 'escape'))
|
||||
const linkedPac = join(await temp('aurora-pac-'), 'linked.pac')
|
||||
await execFileAsync('zip', ['-qry', linkedPac, '.'], { cwd: linkedSource })
|
||||
await expect(installModulePackage(linkedPac, userData)).rejects.toThrow(/symbolic links/)
|
||||
|
||||
const traversalSource = await packageDir()
|
||||
await mkdir(join(traversalSource, 'xx'))
|
||||
await writeFile(join(traversalSource, 'xx', 'evil'), 'unsafe')
|
||||
const traversalPac = join(await temp('aurora-pac-'), 'traversal.pac')
|
||||
await execFileAsync('zip', ['-qr', traversalPac, '.'], { cwd: traversalSource })
|
||||
const archive = await readFile(traversalPac)
|
||||
const original = Buffer.from('xx/evil')
|
||||
const unsafe = Buffer.from('../evil')
|
||||
for (let offset = archive.indexOf(original); offset !== -1; offset = archive.indexOf(original, offset + unsafe.length)) unsafe.copy(archive, offset)
|
||||
await writeFile(traversalPac, archive)
|
||||
await expect(installModulePackage(traversalPac, userData)).rejects.toThrow(/(?:Unsafe \.pac entry path|invalid relative path)/)
|
||||
})
|
||||
it('refreshes after uninstall without touching source', async () => {
|
||||
const userData = await temp('aurora-user-'); const source = await packageDir(); await installModulePackage(source, userData); await uninstallModulePackage('sample-app', userData)
|
||||
expect(await getModuleRegistry(userData)).toEqual([])
|
||||
@@ -64,5 +114,10 @@ describe('external module registry', () => {
|
||||
const packageRoot = resolve(process.cwd(), 'packages/aurora-module-wordpress')
|
||||
const actual = JSON.parse(await readFile(join(packageRoot, 'manifest.json'), 'utf8'))
|
||||
expect(validateModuleManifest(actual).id).toBe('wordpress')
|
||||
const archive = join(await temp('aurora-pac-'), 'wordpress.pac')
|
||||
await execFileAsync('zip', ['-qr', archive, '.'], { cwd: packageRoot })
|
||||
const userData = await temp('aurora-user-')
|
||||
expect((await installModulePackage(archive, userData)).manifest.id).toBe('wordpress')
|
||||
expect((await getModuleRegistry(userData)).map((item) => item.id)).toEqual(['wordpress'])
|
||||
})
|
||||
})
|
||||
|
||||
+105
-12
@@ -1,11 +1,16 @@
|
||||
import { app } from 'electron'
|
||||
import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat } from 'fs/promises'
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path'
|
||||
import extract from 'extract-zip'
|
||||
import yauzl, { type Entry } from 'yauzl'
|
||||
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat } from 'fs/promises'
|
||||
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'path'
|
||||
import type { AuroraAvailableModule, AuroraModuleInstallResult, AuroraModuleManifest, AuroraModuleSetting } from '../shared/types'
|
||||
|
||||
export const CORE_VERSION = '2.0.0-alpha.24'
|
||||
export const MODULE_API_VERSION = '1.0.0'
|
||||
let cache: AuroraModuleManifest[] | null = null
|
||||
const MAX_PAC_ENTRIES = 4096
|
||||
const MAX_PAC_EXPANDED_BYTES = 256 * 1024 * 1024
|
||||
const MAX_MANIFEST_BYTES = 1024 * 1024
|
||||
|
||||
export function moduleDirectory(userData = app.getPath('userData')): string { return join(userData, 'modules') }
|
||||
function validVersion(value: unknown): value is string { return typeof value === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value) }
|
||||
@@ -79,6 +84,62 @@ async function assertSafePackageTree(root: string): Promise<void> {
|
||||
await visit(root)
|
||||
}
|
||||
|
||||
function validatePacEntry(entry: Entry): void {
|
||||
const name = entry.fileName
|
||||
if (!name || name.includes('\\') || name.startsWith('/') || /^[A-Za-z]:/.test(name)) throw new Error(`Unsafe .pac entry path: ${name || '(empty)'}`)
|
||||
if (name.split('/').some((part) => part === '..')) throw new Error(`Unsafe .pac entry path: ${name}`)
|
||||
if ((entry.generalPurposeBitFlag & 0x1) !== 0) throw new Error(`Encrypted .pac entries are not supported: ${name}`)
|
||||
const unixMode = (entry.externalFileAttributes >>> 16) & 0xffff
|
||||
if ((unixMode & 0xf000) === 0xa000) throw new Error(`.pac packages may not contain symbolic links: ${name}`)
|
||||
}
|
||||
|
||||
async function inspectPac(source: string): Promise<AuroraModuleManifest> {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
yauzl.open(source, { lazyEntries: true, decodeStrings: true }, (openError, zip) => {
|
||||
if (openError || !zip) { rejectPromise(openError ?? new Error('Unable to open .pac package')); return }
|
||||
let entries = 0
|
||||
let expandedBytes = 0
|
||||
let manifest: AuroraModuleManifest | null = null
|
||||
let settled = false
|
||||
const fail = (error: unknown): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
zip.close()
|
||||
rejectPromise(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
zip.on('error', fail)
|
||||
zip.on('entry', (entry) => {
|
||||
try {
|
||||
validatePacEntry(entry)
|
||||
entries += 1
|
||||
expandedBytes += entry.uncompressedSize
|
||||
if (entries > MAX_PAC_ENTRIES) throw new Error(`.pac contains more than ${MAX_PAC_ENTRIES} entries`)
|
||||
if (expandedBytes > MAX_PAC_EXPANDED_BYTES) throw new Error('.pac expanded size exceeds 256 MiB')
|
||||
if (entry.fileName !== 'manifest.json') { zip.readEntry(); return }
|
||||
if (entry.uncompressedSize > MAX_MANIFEST_BYTES) throw new Error('.pac manifest exceeds 1 MiB')
|
||||
zip.openReadStream(entry, (streamError, stream) => {
|
||||
if (streamError || !stream) { fail(streamError ?? new Error('Unable to read .pac manifest')); return }
|
||||
const chunks: Buffer[] = []
|
||||
stream.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
stream.on('error', fail)
|
||||
stream.on('end', () => {
|
||||
try { manifest = validateModuleManifest(JSON.parse(Buffer.concat(chunks).toString('utf8'))); zip.readEntry() } catch (error) { fail(error) }
|
||||
})
|
||||
})
|
||||
} catch (error) { fail(error) }
|
||||
})
|
||||
zip.on('end', () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
zip.close()
|
||||
if (!manifest) rejectPromise(new Error('.pac must contain manifest.json at the archive root'))
|
||||
else resolvePromise(manifest)
|
||||
})
|
||||
zip.readEntry()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function getModuleRegistry(userData?: string): Promise<AuroraModuleManifest[]> {
|
||||
if (!userData && cache) return cache
|
||||
const dir = moduleDirectory(userData)
|
||||
@@ -101,7 +162,9 @@ export function invalidateModuleRegistry(): void { cache = null }
|
||||
|
||||
function catalogDirectories(): string[] {
|
||||
const configured = (process.env.AURORA_MODULE_PATH ?? '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean)
|
||||
const besideImage = process.env.APPIMAGE ? [join(dirname(process.env.APPIMAGE), 'modules')] : []
|
||||
const besideImage = process.env.APPIMAGE
|
||||
? [dirname(process.env.APPIMAGE), join(dirname(process.env.APPIMAGE), 'modules')]
|
||||
: []
|
||||
return [...configured, ...besideImage, join(process.cwd(), 'modules'), join(process.cwd(), 'packages'), join(app.getAppPath(), 'packages')]
|
||||
}
|
||||
|
||||
@@ -110,10 +173,12 @@ export async function getAvailableModulePackages(): Promise<AuroraAvailableModul
|
||||
for (const directory of catalogDirectories()) {
|
||||
try {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
if (!entry.isDirectory() && !(entry.isFile() && extname(entry.name).toLowerCase() === '.pac')) continue
|
||||
const sourcePath = join(directory, entry.name)
|
||||
try {
|
||||
const manifest = validateModuleManifest(JSON.parse(await readFile(join(sourcePath, 'manifest.json'), 'utf8')))
|
||||
const manifest = entry.isDirectory()
|
||||
? validateModuleManifest(JSON.parse(await readFile(join(sourcePath, 'manifest.json'), 'utf8')))
|
||||
: await inspectPac(sourcePath)
|
||||
if (!found.has(manifest.id)) found.set(manifest.id, { manifest, sourcePath })
|
||||
} catch { /* unrelated or incompatible package */ }
|
||||
}
|
||||
@@ -124,17 +189,45 @@ export async function getAvailableModulePackages(): Promise<AuroraAvailableModul
|
||||
|
||||
export async function installModulePackage(source: string, userData?: string): Promise<AuroraModuleInstallResult> {
|
||||
if (!isAbsolute(source)) throw new Error('Module package path must be absolute')
|
||||
if (!(await stat(source)).isDirectory()) throw new Error('Select an unpacked local module directory')
|
||||
await assertSafePackageTree(source)
|
||||
const manifest = validateModuleManifest(JSON.parse(await readFile(join(source, 'manifest.json'), 'utf8')))
|
||||
const sourceInfo = await stat(source)
|
||||
const isDirectory = sourceInfo.isDirectory()
|
||||
const isPac = sourceInfo.isFile() && extname(source).toLowerCase() === '.pac'
|
||||
if (!isDirectory && !isPac) throw new Error('Select an Aurora .pac file or unpacked module directory')
|
||||
const modules = moduleDirectory(userData)
|
||||
await mkdir(modules, { recursive: true })
|
||||
const staging = await mkdtemp(join(modules, '.install-'))
|
||||
let manifest: AuroraModuleManifest
|
||||
try {
|
||||
if (isDirectory) {
|
||||
await assertSafePackageTree(source)
|
||||
await cp(source, staging, { recursive: true, errorOnExist: false })
|
||||
} else {
|
||||
manifest = await inspectPac(source)
|
||||
await extract(source, { dir: staging })
|
||||
}
|
||||
await assertSafePackageTree(staging)
|
||||
manifest = validateModuleManifest(JSON.parse(await readFile(join(staging, 'manifest.json'), 'utf8')))
|
||||
} catch (error) {
|
||||
await rm(staging, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
const destination = resolve(modules, manifest.id)
|
||||
if (dirname(destination) !== resolve(modules) || basename(destination) !== manifest.id) throw new Error('Unsafe module destination')
|
||||
const staging = join(modules, `.${manifest.id}-${process.pid}-${Date.now()}`)
|
||||
await cp(source, staging, { recursive: true, errorOnExist: true })
|
||||
await rm(destination, { recursive: true, force: true })
|
||||
await rename(staging, destination)
|
||||
const backup = join(modules, `.backup-${manifest.id}-${process.pid}-${Date.now()}`)
|
||||
let hadExisting = false
|
||||
try {
|
||||
try { await stat(destination); hadExisting = true } catch { hadExisting = false }
|
||||
if (hadExisting) await rename(destination, backup)
|
||||
await rename(staging, destination)
|
||||
await rm(backup, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
await rm(staging, { recursive: true, force: true })
|
||||
if (hadExisting) {
|
||||
await rm(destination, { recursive: true, force: true })
|
||||
await rename(backup, destination)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
invalidateModuleRegistry()
|
||||
return { manifest, installedPath: destination }
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function GlobalModuleManager({ onClose }: { onClose: () => void }): React
|
||||
})}
|
||||
</div>}
|
||||
</div>
|
||||
<footer className="flex justify-between border-t border-neutral-200 bg-neutral-50 px-5 py-3 dark:border-white/10 dark:bg-white/[0.03]"><button type="button" onClick={() => installFromFolder.mutate()} className="text-sm font-medium text-neutral-500 hover:text-cyan-700 dark:hover:text-cyan-300">Install from folder…</button><button type="button" onClick={onClose} className="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-medium dark:border-white/10">Close</button></footer>
|
||||
<footer className="flex justify-between border-t border-neutral-200 bg-neutral-50 px-5 py-3 dark:border-white/10 dark:bg-white/[0.03]"><button type="button" onClick={() => installFromFolder.mutate()} className="text-sm font-medium text-neutral-500 hover:text-cyan-700 dark:hover:text-cyan-300">Install .pac or folder…</button><button type="button" onClick={onClose} className="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-medium dark:border-white/10">Close</button></footer>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user