fix(sign-plugin): prevent symlink escape check bypass and add test coverage#2721
fix(sign-plugin): prevent symlink escape check bypass and add test coverage#2721tolzhabayev wants to merge 4 commits into
Conversation
@grafana/create-plugin
@grafana/eslint-config
@grafana/eslint-plugin-plugins
@grafana/plugin-docs-cli
@grafana/plugin-docs-parser
@grafana/plugin-e2e
@grafana/plugin-meta-extractor
@grafana/plugin-types-bundler
@grafana/react-detect
@grafana/sign-plugin
@grafana/tsconfig
commit: |
| @@ -0,0 +1,34 @@ | |||
| import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'; | |||
There was a problem hiding this comment.
We use https://github.com/raszi/node-tmp in this repo to handle creation and deletion of temp directories and files as recommended by codeql which was creating noise about os.tmpdir usage in tests.
codeql: https://github.com/grafana/plugin-tools/security/code-scanning/24
fix pr: #2010
There was a problem hiding this comment.
Good call - switched the fixtures over to tmp in 797162c.
|
@jackw need another 👍 |
There was a problem hiding this comment.
Pull request overview
This PR hardens @grafana/sign-plugin’s manifest generation against symlink-escape bypasses, refactors signing/saving to throw errors (so the command owns exiting/output), and adds substantial unit + integration test coverage across the signing pipeline.
Changes:
- Strengthens symlink handling in
buildManifest()to prevent base-directory escape via crafted symlink targets. - Refactors
signManifest()/saveManifest()to throw on failure and moves token resolution + exit behavior into thesigncommand. - Adds comprehensive Vitest coverage for manifest creation, signing error paths, CLI behavior, and create-plugin version detection.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/sign-plugin/src/utils/tests/fixtures.ts | Adds shared temp-dir + file-writing helpers for tests. |
| packages/sign-plugin/src/utils/pluginValidation.test.ts | Expands coverage for plugin.json validation and root URL validation. |
| packages/sign-plugin/src/utils/manifest.ts | Fixes symlink escape checks; makes signing/saving throw errors instead of exiting. |
| packages/sign-plugin/src/utils/manifest.test.ts | Adds unit tests for manifest building, symlinks, signing, and saving. |
| packages/sign-plugin/src/utils/getCreatePluginVersion.test.ts | Adds tests for reading .config/.cprc.json version behavior. |
| packages/sign-plugin/src/commands/sign.command.ts | Moves token resolution into the command; passes token to signManifest(). |
| packages/sign-plugin/src/commands/sign.command.test.ts | Adds CLI integration tests ensuring correct behavior and failure handling. |
| packages/sign-plugin/package.json | Adds devDependencies to support new test fixtures. |
| package-lock.json | Lockfile updates for the new devDependencies. |
| import { mkdirSync, realpathSync, writeFileSync } from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import { dirSync } from 'tmp'; | ||
|
|
||
| export const DEFAULT_PLUGIN_JSON = { | ||
| id: 'grafana-test-app', | ||
| type: 'app', | ||
| info: { | ||
| version: '1.0.0', | ||
| }, | ||
| }; | ||
|
|
||
| // realpathSync prevents false symlink-escape errors on macOS where the temp dir | ||
| // lives under /var/... which resolves to /private/var/... | ||
| export function createTempDir(): string { | ||
| return realpathSync(dirSync({ prefix: 'sign-plugin-test-', unsafeCleanup: true }).name); | ||
| } |
| "devDependencies": { | ||
| "@libs/output": "^1.0.3", | ||
| "@libs/version": "^1.0.2", | ||
| "@types/minimist": "^1.2.5" | ||
| "@types/minimist": "^1.2.5", | ||
| "@types/tmp": "0.2.6", | ||
| "tmp": "0.2.7" | ||
| }, |
| export function saveManifest(dir: string, signedManifest: string) { | ||
| try { | ||
| writeFileSync(path.join(dir, MANIFEST_FILE), signedManifest); | ||
| return true; | ||
| } catch (error) { | ||
| output.error({ | ||
| title: 'Error saving manifest', | ||
| body: [`Failed to write signed manifest to ${dir}.`], | ||
| }); | ||
| process.exit(1); | ||
| throw new Error(`Failed to write signed manifest to ${dir}.`); | ||
| } |
mckn
left a comment
There was a problem hiding this comment.
LGTM! Great work on this. Something that we might want to open an issue about:
symlink-to-directory within base dir: walk classifies entries as either isFile() or isSymbolicLink() (via lstat). A symlink pointing to a directory inside the base dir would pass the escape check and be yielded, then hashFile would fail trying to read a directory as a file. This isn't introduced here and is out of scope, but worth a follow-up issue.
| @@ -0,0 +1,34 @@ | |||
| import { mkdirSync, realpathSync, writeFileSync } from 'node:fs'; | |||
| import path from 'node:path'; | |||
| import { dirSync } from 'tmp'; | |||
There was a problem hiding this comment.
I think it might be worth looking into dropping the dependency on 'tmp' (if possible).
It might be possible to replace it with: mkdtempSync + realpathSync from node:fs
There was a problem hiding this comment.
Yeah, I left it because of #2721 (comment)
| }); | ||
| }); | ||
|
|
||
| describe('saveManifest', () => { |
There was a problem hiding this comment.
Should we also update this test to use the "tempDirs cleanup pattern" that you have in the other tests? If it is possible.
There was a problem hiding this comment.
Should we also update this test to use the "tempDirs cleanup pattern" that you have in the other tests? If it is possible.
good idea, here it is c1a24da#diff-51c38a2838733feeb986bfeafc9e94a52f57c8be8014e2bf0a9b92f267b8b201R175
Adds unit tests for buildManifest, signManifest, saveManifest, pluginValidation and getCreatePluginVersion, plus integration tests for the sign command against real fixture dirs with only the network mocked. To make the error paths testable, signManifest now takes the token as a parameter and throws on failure instead of calling process.exit; saveManifest likewise throws. Token resolution (including the GRAFANA_API_KEY deprecation warning) moved into the sign command, which owns all output and exits. User-facing behavior is unchanged.
…precedence The symlink escape check used a string prefix comparison, so a target in a sibling directory like <baseDir>-evil passed as inside the base directory. Replace it with a path.relative() check and add a regression test for the sibling-prefix bypass. Token resolution moved back inside the signing flow, right before signManifest, so an invalid root URL or malformed manifest is reported before a missing token, matching the original error precedence. Add tests for that precedence and for GRAFANA_ACCESS_POLICY_TOKEN winning over GRAFANA_API_KEY while the deprecation warning is preserved.
Address review feedback: keep the original write error (EACCES/ENOENT) as the cause when saveManifest re-throws, and apply the shared tempDirs cleanup pattern to the saveManifest test block.
208b7a5 to
c1a24da
Compare
| const realPath = await fs.realpath(entry); | ||
| if (!realPath.startsWith(baseDir)) { | ||
| // A prefix check would treat sibling paths like <baseDir>-evil as inside the base directory. | ||
| const relativeToBase = path.relative(baseDir, realPath); | ||
| const isOutsideBaseDir = | ||
| relativeToBase === '..' || relativeToBase.startsWith('..' + path.sep) || path.isAbsolute(relativeToBase); |
| const signedManifest = await signManifest(manifest, token); | ||
|
|
||
| saveManifest(pluginDistDir, signedManifest); | ||
| output.success({ |
| function formatServerError(data: string): string { | ||
| try { | ||
| return Object.entries(JSON.parse(data)) | ||
| .map(([key, value]) => `${key}: ${value}`) | ||
| .join(', '); | ||
| } catch (err) { | ||
| return data; | ||
| } | ||
| } |

What this PR does / why we need it:
@grafana/sign-pluginhad almost no test coverage (a single file with two assertions). This PR brings it to 36 tests across 4 files:buildManifest— sha256 per file, nested directories,MANIFEST.txtexclusion, posix path keys (regression guard for the Windows\→/sanitisation), internal symlinks included, symlinks escaping the base directory rejected, missingplugin.json. Runs against real temp dirs created with thetmppackage (the repo convention for test temp dirs, see Create Plugin: Update tests to use tmp package #2010 — keeps CodeQL from flaggingos.tmpdir()usage).signcommand integration tests — real fixture dist dirs with onlypostDatamocked: happy path writes the signedMANIFEST.txt,GRAFANA_COM_URLoverride,--signatureType/--rootUrlspassthrough, invalid root URL exits before any network call, missing token exits,GRAFANA_API_KEYdeprecation warning still proceeds, non-200/network errors exit without writing a manifest,createPlugin.versionpicked up from.config/.cprc.json.signManifest/saveManifestunits — success, non-200 with JSON and non-JSON bodies, network error propagation, write failure.pluginValidation+getCreatePluginVersion— all validation branches,assertRootUrlIsValid, present/absent/malformed.cprc.json.To make error paths testable,
signManifestnow takes the token as a parameter and throws on failure instead of callingprocess.exit(1);saveManifestlikewise throws. Token resolution (including theGRAFANA_API_KEYdeprecation warning) moved into the sign command, which owns all output and exits. The integration tests were written before this refactor and pass unchanged after it, so user-facing behavior is preserved.Which issue(s) this PR fixes:
n/a
Special notes for your reviewer:
os.tmpdir()returns/var/...which resolves to/private/var/..., which would otherwise falsely trip the symlink-escape check inwalk.getPluginJsoninpluginValidation.tsusesrequire()in an ESM package and has no callers — it looks like dead code, but removing it felt out of scope for this PR.