Skip to content

Commit 7ce6e75

Browse files
authored
chore: add validation rules for package identifiers (#3012)
1 parent a5984ae commit 7ce6e75

2 files changed

Lines changed: 92 additions & 2 deletions

File tree

packages/desktop/app/javascripts/Main/Packages/PackageManager.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,8 +386,31 @@ async function installComponent(
386386
}
387387
}
388388

389+
function validatePackageIdentifier(identifier: string) {
390+
if (!identifier) {
391+
throw new Error('Package identifier must not be empty')
392+
}
393+
394+
if (identifier.includes('/') || identifier.includes('\\') || identifier === '.' || identifier === '..') {
395+
throw new Error(`Invalid package identifier: ${identifier}`)
396+
}
397+
}
398+
399+
function assertPathWithinExtensions(absolutePath: string) {
400+
const extensionsRoot = path.resolve(Paths.userDataDir, Paths.extensionsDirRelative)
401+
const resolvedPath = path.resolve(absolutePath)
402+
const relativeToExtensions = path.relative(extensionsRoot, resolvedPath)
403+
404+
if (relativeToExtensions.startsWith('..') || path.isAbsolute(relativeToExtensions)) {
405+
throw new Error(`Path escapes extensions directory: ${absolutePath}`)
406+
}
407+
}
408+
389409
function pathsForComponent(component: Pick<Component, 'content'>) {
390-
const relativePath = path.join(Paths.extensionsDirRelative, component.content!.package_info.identifier)
410+
const identifier = component.content!.package_info.identifier
411+
validatePackageIdentifier(identifier)
412+
413+
const relativePath = path.join(Paths.extensionsDirRelative, identifier)
391414
const absolutePath = path.join(Paths.userDataDir, relativePath)
392415
const downloadPath = path.join(Paths.tempDir, AppName, 'downloads', component.content!.name + '.zip')
393416

@@ -404,7 +427,9 @@ async function uninstallComponent(mapping: MappingFileHandler, uuid: string) {
404427
/** No mapping for component */
405428
return
406429
}
407-
const result = await new FilesManager().deleteDir(path.join(Paths.userDataDir, componentMapping.location))
430+
const absolutePath = path.join(Paths.userDataDir, componentMapping.location)
431+
assertPathWithinExtensions(absolutePath)
432+
const result = await new FilesManager().deleteDir(absolutePath)
408433
if (!result.isFailed()) {
409434
mapping.remove(uuid)
410435
}

packages/desktop/test/packageManager.spec.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,71 @@ test("doesn't download anything when two install/uninstall tasks are queued", as
143143
t.is(downloadFileCallCount, 1)
144144
})
145145

146+
test('does not uninstall paths outside extensions from poisoned mapping', async (t) => {
147+
await packageManager.syncComponents([fakeComponent()])
148+
await new Promise((resolve) => setTimeout(resolve, 200))
149+
150+
const escapeDir = path.join(tmpDir.path, 'escape')
151+
const markerPath = path.join(escapeDir, 'marker.txt')
152+
await ensureDirectoryExists(escapeDir)
153+
await fs.writeFile(markerPath, 'keep')
154+
155+
const poisonedLocations = [path.join('Extensions', '..', 'escape'), path.join('..', 'escape')]
156+
157+
for (const location of poisonedLocations) {
158+
await fs.writeFile(
159+
path.join(contentDir, 'mapping.json'),
160+
JSON.stringify({
161+
[uuid]: { location, version },
162+
}),
163+
)
164+
165+
await packageManager.syncComponents([fakeComponent({ deleted: true })])
166+
await new Promise((resolve) => setTimeout(resolve, 200))
167+
168+
t.true(await fs.stat(markerPath).then(() => true), `marker preserved for location ${location}`)
169+
t.deepEqual(await readJSONFile(path.join(contentDir, 'mapping.json')), {
170+
[uuid]: { location, version },
171+
})
172+
}
173+
174+
t.true(await fs.stat(path.join(contentDir, identifier)).then(() => true))
175+
})
176+
177+
test('rejects path traversal in package identifier', async (t) => {
178+
const traversalIdentifiers = ['../escape', 'foo/../../escape', '..', '.', 'foo/bar']
179+
const extensionsParent = path.dirname(contentDir)
180+
181+
for (const badIdentifier of traversalIdentifiers) {
182+
const before = await fs.readdir(contentDir)
183+
const parentBefore = await fs.readdir(extensionsParent)
184+
185+
downloadFileCallCount = 0
186+
await packageManager.syncComponents([
187+
{
188+
...fakeComponent({ modifier: badIdentifier }),
189+
content: {
190+
...fakeComponent().content,
191+
name: `Bad ${badIdentifier}`,
192+
package_info: {
193+
...fakeComponent().content.package_info,
194+
identifier: badIdentifier,
195+
},
196+
},
197+
},
198+
])
199+
await new Promise((resolve) => setTimeout(resolve, 200))
200+
201+
t.is(downloadFileCallCount, 0, `should not download for identifier ${badIdentifier}`)
202+
t.deepEqual(await fs.readdir(contentDir), before, `extensions dir unchanged for ${badIdentifier}`)
203+
t.deepEqual(
204+
await fs.readdir(extensionsParent),
205+
parentBefore,
206+
`no directory escape for ${badIdentifier}`,
207+
)
208+
}
209+
})
210+
146211
test("Relies on download_url's version field to store the version number", async (t) => {
147212
await packageManager.syncComponents([fakeComponent()])
148213
await new Promise((resolve) => setTimeout(resolve, 200))

0 commit comments

Comments
 (0)