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
+9 -6
View File
@@ -11,7 +11,10 @@
- Application packages are discovered and installed into Aurora's Electron user-data `modules` directory.
- The module registry is invalidated after install, update, and uninstall operations.
- Package manifests are validated for identity, version, category, dependencies, conflicts, settings, Core/API compatibility, project contributions, and safe relative entry paths.
- Installation rejects symbolic links and paths that could escape the package or module destination.
- Native `.pac` files are ZIP-compressed Aurora packages with `manifest.json` at archive root.
- A `.pac` placed beside the AppImage or in its `modules` folder is discovered automatically and appears in the Modules screen.
- `.pac` inspection rejects encrypted entries, symbolic links, path traversal, absolute/drive paths, excessive entry counts, and expanded archives larger than 256 MiB before extraction.
- Installation uses staging plus rollback-safe replacement, so a failed update preserves the currently installed module.
- Application choices in New Project come only from the installed-module registry.
- Project actions and metadata summaries are declarative module contributions. Core no longer contains WordPress-specific admin or multisite presentation.
- The `moduleMetadata` capability is the versioned bridge used by trusted module lifecycle hooks.
@@ -41,13 +44,14 @@ npm run build
npx electron-builder --linux AppImage
```
Automated result: 6 test files and 23 tests passed. Coverage includes:
Automated result: 6 test files and 26 tests passed. Coverage includes:
- Empty registry
- Available local packages
- Valid installation and registry refresh
- Invalid and incompatible manifest rejection
- Unsafe package path and symbolic-link rejection
- `.pac` discovery, installation, traversal rejection, and archive symlink rejection
- Package update
- Registry refresh after uninstall
- Preservation of source packages and existing project data
@@ -67,18 +71,17 @@ Live project smoke result for project `24`:
## Artifacts
- Core AppImage: `/home/reaper/Documents/Codex/aurora/dist/final-alpha24/aurora-dockside-2.0.0-alpha.24.AppImage`
- WordPress package archive: `/home/reaper/Documents/Codex/aurora/dist/final-alpha24/aurora-module-wordpress-1.2.0.tar.gz`
- Directly installable WordPress package: `/home/reaper/Documents/Codex/aurora/dist/final-alpha24/aurora-module-wordpress-1.2.0.pac`
- Directly installable unpacked WordPress package: `/home/reaper/Documents/Codex/aurora/packages/aurora-module-wordpress`
SHA-256:
```text
fe700b5528ad7ddcdae43ced5651801370b2b09f8ed711fb447179281b6a9257 aurora-dockside-2.0.0-alpha.24.AppImage
942068c4eeb9b638f8aa6041dc8e5c15cec95fa6901fa24ed197221469ce37e9 aurora-module-wordpress-1.2.0.tar.gz
1964de899632f7dca86b5ee6485d5c2bc8dc0cffcc7a9de4479ea7eef25e9e76 aurora-dockside-2.0.0-alpha.24.AppImage
d2165af4b75ab888c6d35a750a449205123866231ba5e98e1cbe9bcaa8738f46 aurora-module-wordpress-1.2.0.pac
```
## Known limitations
- The module picker installs unpacked package directories. Extract the `.tar.gz` before selecting it in **Install from folder**.
- Clean-registry install/remove behavior was exercised through the real registry implementation in automated temporary-directory tests. The live GUI smoke used the already installed local WordPress package and project `24`.
- AppImage systems without working FUSE can use `--appimage-extract` and launch `squashfs-root/AppRun --no-sandbox`.
+4 -1
View File
@@ -14,8 +14,10 @@
"@tanstack/react-query": "^5.101.4",
"clsx": "^2.1.1",
"electron-updater": "^6.8.9",
"extract-zip": "^2.0.1",
"lucide-react": "^1.28.0",
"tailwind-merge": "^3.6.0",
"yauzl": "^2.10.0",
"zustand": "^5.0.14"
},
"devDependencies": {
@@ -29,6 +31,7 @@
"@types/node": "^22.19.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/yauzl": "^2.10.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.1.10",
"@vitest/ui": "^4.1.10",
@@ -3164,8 +3167,8 @@
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
"devOptional": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*"
}
+3
View File
@@ -34,8 +34,10 @@
"@tanstack/react-query": "^5.101.4",
"clsx": "^2.1.1",
"electron-updater": "^6.8.9",
"extract-zip": "^2.0.1",
"lucide-react": "^1.28.0",
"tailwind-merge": "^3.6.0",
"yauzl": "^2.10.0",
"zustand": "^5.0.14"
},
"devDependencies": {
@@ -49,6 +51,7 @@
"@types/node": "^22.19.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/yauzl": "^2.10.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.1.10",
"@vitest/ui": "^4.1.10",
+1 -1
View File
@@ -1,5 +1,5 @@
# Aurora WordPress module
Local trusted extension package for Aurora Dockside 2.0.0-alpha.24. Install the unpacked package directory from **New Project → Install local module**. Its manifest contributes creation fields; its main-process lifecycle uses only the versioned Core context supplied by module API 1.0.0.
Local trusted extension package for Aurora Dockside 2.0.0-alpha.24. Install the distributed `.pac` file from **Modules → Install .pac or folder**. The unpacked package directory remains installable for development. Its manifest contributes creation fields; its main-process lifecycle uses only the versioned Core context supplied by module API 1.0.0.
Uninstalling this package removes only the copy in Aurora's user-data `modules/wordpress` directory. It never removes project files or databases.
+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>
}