feat: add secure pac module packages

This commit is contained in:
reaper
2026-08-14 17:43:16 -05:00
parent afce94a0a8
commit 9a8476507b
8 changed files with 182 additions and 22 deletions
+4 -1
View File
@@ -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])
})
+55
View File
@@ -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
View File
@@ -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>
}