app: 0001: Verify external plugin manifests - #6827
Conversation
Cover manifest selection, digest checks, and download failures.
Exercise external manifest setup with a verified local archive.
Support product manifests and verify remote plugin archives. Co-authored-by: René Dudfield <renedudfield@microsoft.com>
|
[APPROVALNOTIFIER] This PR is APPROVED This pull-request has been approved by: illume The full list of commands accepted by this bot can be found here. The pull request process is described here DetailsNeeds approval from an approver in each of these files:
Approvers can indicate their approval by writing |
There was a problem hiding this comment.
Pull request overview
This PR adds support for selecting an external Electron app plugin manifest at build time via HEADLAMP_BUILD_MANIFEST, ensures the selected manifest is packaged under Headlamp’s runtime manifest name, and hardens plugin archive handling by enforcing/validating SHA-256 digests (for remote archives from external manifests) plus safer download redirect behavior.
Changes:
- Add
HEADLAMP_BUILD_MANIFESTresolution + manifest loading helper (app/scripts/build-manifest.js) and wire it into plugin setup. - Enforce/verify SHA-256 digests for plugin archives and harden
httpsdownload handling (URL validation, HTTPS-only, redirect limits). - Package the chosen manifest through an Electron Builder config wrapper, and add unit + e2e coverage for the new behavior.
Note: CI status and PR commit history (e.g., merge commits / coherence) were not available in the provided context, so they could not be independently verified here.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| app/scripts/setup-plugins.js | Loads selected build manifest; verifies SHA-256 digests; tightens archive download redirect/URL handling; exports helpers for tests. |
| app/scripts/build-manifest.js | Adds manifest path resolution and JSON loading (supports HEADLAMP_BUILD_MANIFEST). |
| app/package.json | Updates Electron Builder invocations to use the new electron-builder.config.js. |
| app/electron/build-manifest.test.ts | Adds unit tests for manifest selection/packaging and archive integrity/download behavior. |
| app/electron-builder.config.js | Wraps Electron Builder config to package the selected manifest under app-build-manifest.json. |
| app/e2e-tests/tests/externalPluginManifest.spec.ts | Adds e2e test that runs setup-plugins.js with an external manifest and a verified local plugin archive. |
Type and document the manifest and plugin setup entrypoints.
Reject unsafe manifests and process archives with bounded memory.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
Suppressed comments (3)
package.json:74
- Same issue as
app:build: invoking a.tsscript with plainnodeis expected to fail. Include the TypeScript runner flag when starting the app so plugin setup runs reliably.
"app:start": "cd app && node ./scripts/setup-plugins.ts && npm run start",
Makefile:335
setup-plugins.tsis executed with plainnode, which is expected to fail for TypeScript sources unless a loader/flag is used. Add the TypeScript runner flag here somake run-appworks after this change.
cd app && npm install && node ./scripts/setup-plugins.ts && npm run start
Makefile:338
setup-plugins.tsis executed with plainnode, which is expected to fail for TypeScript sources unless a loader/flag is used. Add the TypeScript runner flag here somake run-only-appworks after this change.
cd app && npm install && node ./scripts/setup-plugins.ts && npm run dev-only-app
Use Node type stripping consistently across setup entrypoints.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (3)
app/scripts/setup-plugins.ts:204
downloadFilecurrently allows 6 redirects because it rejects only whenredirectCount > 5. WithredirectCountdocumented as “redirects followed so far”, the usual intent is to allow at most 5 redirects and reject on the 6th hop (i.e. whenredirectCount >= 5before issuing the next request). This also means the “excessive redirects” test is not exercising the actual limit boundary.
if (parsedUrl.protocol !== 'https:') {
reject(new Error(`Plugin archive URL must use HTTPS: ${url}`));
return;
}
if (redirectCount > 5) {
app/scripts/setup-plugins.ts:268
fetchArchivecreates a temporary directory but only deletes the downloaded archive file. The extracted contents and the temporary directory itself are never removed, so repeated builds can accumulateheadlamp-plugins*directories under the system temp folder.
const temporaryFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'headlamp-plugins'));
const archivePath = path.join(temporaryFolder, archiveName);
await downloadFile(url, archivePath);
verifyArchiveDigest(archivePath, sha256);
await extractArchive(name, archivePath, temporaryFolder);
app/scripts/setup-plugins.ts:289
manifestis parsed from JSON and then cast toBuildManifest, butmain()assumes everypluginsentry is a well-formed object and destructures it immediately. A malformed manifest like{"plugins":[null]}or an entry missingnamewill throw a non-actionable runtime error (or produceundefinedpaths). Adding minimal runtime validation here makes the “verify external manifests” behavior more robust and yields clearer failures.
for (const plugin of manifest.plugins ?? []) {
const { name, archive, file, sha256 } = plugin;
validatePluginSource(plugin);
b74d4a3 to
6524e10
Compare
84dbb52 to
0e57f13
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 13 changed files in this pull request and generated 1 comment.
Files not reviewed (1)
- app/package-lock.json: Generated file
Suppressed comments (4)
app/scripts/setup-plugins.ts:249
- The download promise only observes the destination stream. If the HTTP response aborts or errors mid-body,
response.pipe(file)can leave a partial file and the promise pending or surface an unhandled response error; the digest check is then never reached reliably. Usestream.pipeline(response, file)and resolve only after that promise completes so premature closes reject and clean up.
const file = fs.createWriteStream(destinationPath);
response.pipe(file);
file.on('error', reject);
app/scripts/setup-plugins.ts:98
- This still accepts Windows-invalid directory names such as
foo:bar,NUL, control characters, or names ending in a dot/space. Those values pass validation but fail or alias whenfs.mkdirSync(path.join(PLUGIN_FOLDER, name))runs in the Windows build. Validate plugin names as portable path segments, including reserved characters/device names and trailing dots/spaces, before clearing the staging directory.
/[\\/]/.test(plugin.name)
app/scripts/setup-plugins.ts:263
- This treats every non-2xx response carrying
Locationas a redirect, so a failed 404/500 response can be followed instead of rejected as promised. Also, a malformedLocationmakesnew URLthrow inside the asynchronous callback, bypassingreject. Restrict this branch to actual redirect status codes and convert malformed locations into a rejected download.
} else if (response.headers.location) {
const redirectUrl = new URL(response.headers.location, parsedUrl).toString();
app/scripts/setup-plugins.ts:1
- The PR history introduces the JavaScript implementation in
e18bf2d, replaces it with a 669-line TypeScript conversion in158281d, then applies a 110-line hardening correction in662c3d4and runtime-entrypoint corrections in0e57f13. This significantly rewrites and fixes the same feature across later commits, making the commit-by-commit flow difficult to review. Please squash/reorder these commits so the implementation lands in its final validated form and each remaining commit is coherent.
import crypto from 'node:crypto';
ebf41ec to
e0df468
Compare
e0df468 to
0b30470
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 13 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- app/package-lock.json: Generated file
Suppressed comments (6)
app/scripts/setup-plugins.ts:285
- This remote-download temporary directory is also left behind; deleting only
archivePathretains the extracted archive contents, and failures retain both contents and the downloaded file. Wrap download, verification, and extraction intry/finallyand recursively remove the temporary directory.
const temporaryFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'headlamp-plugins'));
app/scripts/setup-plugins.ts:242
- The download only observes the destination stream. If the HTTP response aborts or errors mid-body,
response.pipe(file)can leave this promise pending or surface an unhandled response error, so setup does not reliably reject failed downloads. Use the already importedpipelineso source errors and premature closes reject the promise.
const file = fs.createWriteStream(destinationPath);
response.pipe(file);
file.on('error', reject);
app/scripts/setup-plugins.ts:248
- This follows any non-2xx response carrying
Location, including 4xx/5xx failures, contradicting the promised rejection of unsuccessful downloads. Also,new URL()can throw inside this asynchronous callback, bypassingreject; restrict redirects to redirect status codes and convert malformed locations into a rejected promise.
} else if (response.headers.location) {
const redirectUrl = new URL(response.headers.location, parsedUrl).toString();
app/scripts/setup-plugins.ts:100
- This validation still accepts names that cannot be created reliably by the Windows build, such as
foo:bar,NUL, control characters, or names ending in a dot/space. They pass validation but fail or alias atmkdirSync, after the staging directory has been cleared. Validatenameas a portable path segment.
/[\\/]/.test(plugin.name)
app/scripts/setup-plugins.ts:167
- The default extraction directory is never removed. Every local
fileentry leaves a complete extracted plugin tree under the system temp directory, including on extraction/copy failures. Track whetherextractArchivecreated the directory and remove owned temporary directories in afinallyblock.
This issue also appears on line 285 of the same file.
temporaryFolder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'headlamp-plugins'))
app/scripts/setup-plugins.ts:1
- The PR history introduces the JavaScript implementation in
e18bf2d, replaces it with a 669-line TypeScript conversion in158281d, then applies a 177-line hardening correction in9a96050and runtime-entrypoint corrections ine0df468. This preserves known-broken intermediate states and makes the security-sensitive change difficult to review commit by commit. Please squash/reorder these commits so the implementation lands in its final validated form and each remaining commit is coherent.
import crypto from 'node:crypto';
unlikelyzero
left a comment
There was a problem hiding this comment.
Overall this is a solid start on archive verification, and the redirect re-validation and digest-format checks are done carefully. One thing outside the changed lines: app/app-build-manifest.json (the manifest Headlamp actually ships with) doesn't declare a sha256 for any of its three plugins, and requireDigest only applies when externalManifest is true. So as it stands, this PR's own verification feature never runs against our official build - it only kicks in for someone else's manifest. Might be worth pinning digests on our own manifest as part of this PR, or at least filing a fast follow, otherwise this reads as verification for third parties but not for us.
A few more things inline.
bcf45d4 to
034a6fc
Compare
This is taken care in this following PR now: Good catch! |
034a6fc to
965e1b9
Compare
965e1b9 to
782d622
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 14 changed files in this pull request and generated 1 comment.
Files not reviewed (1)
- app/package-lock.json: Generated file
Suppressed comments (3)
package.json:13
- Node 22.6 does not reliably run this ESM-style
.tsentrypoint in this package:.tsfollows.js, the nearest package has notype: module, and syntax detection only became enabled by default in Node 22.7. The new floor therefore accepts 22.6.x even though the build/start commands fail on the entrypoint'simport/import.metasyntax. Raise all engine/lock metadata to at least 22.7.0, or make the entrypoints explicitly ESM (for example,.mts) while retaining 22.6 support.
"node": ">=22.6.0"
app/scripts/setup-plugins.ts:493
- If the staging rename succeeds but deleting
backupFolderthrows, this catch sees thatPLUGIN_FOLDERalready exists, skips restoration, and rethrows. Setup then reports failure after replacing the previous plugin set and leaves the backup behind, contradicting the failure-preservation guarantee. Separate the swap rollback from best-effort backup cleanup, or remove the new folder and restore the backup before propagating the cleanup error.
fs.rmSync(backupFolder, { recursive: true, force: true });
} catch (error) {
if (hadExistingFolder && !fs.existsSync(PLUGIN_FOLDER) && fs.existsSync(backupFolder)) {
fs.renameSync(backupFolder, PLUGIN_FOLDER);
app/scripts/setup-plugins.ts:295
- The PR history significantly rewrites the same setup implementation across commits: the initial archive implementation is converted in
84b8d166, then changed by 470 lines infddb586f, and the runtime invocations are corrected in782d622f. Please squash or reorder these corrective commits into a coherent implementation sequence so each commit is reviewable and runnable.
export function downloadFile(
Summary
HEADLAMP_BUILD_MANIFESTapp-build-manifest.jsonplugins, andproxy-urlsfields before Electron Builder packages them*wildcard syntaxTest coverage
The unit and e2e commits intentionally precede the implementation commit.
build-manifest.ts, 86.81% forsetup-plugins.ts)setup-plugins.tsNode process, verifies a failed digest preserves the previous plugin set, then installs the verified selected plugin and removes stale entriesOrigin
This upstreams the plugin-manifest work from the original downstream review, Azure/aks-desktop__UNCLEAN#228, authored by Oleksandr Dubenko. The resulting
0001-headlamp-upstream-external-plugin-manifest.patchis carried into Azure/aks-desktop#823. The current Azure source-package commits that retain and consume the patch are Azure/aks-desktop@68e024d and Azure/aks-desktop@c3f501e.Assisted by copilot