Reported against @opensea/tool-sdk@0.28.5 (npm latest) and present unchanged at main. Filed as a public issue because private vulnerability reporting is not enabled on this repository and there is no SECURITY.md; enabling PVR would be worth doing.
CWE-78, OS command injection. Severity: low — the reasoning is in “The arguments against this report” below, which I have written out rather than left for you to find.
deploy builds a shell command string by interpolation and hands it to execSync, which runs it through cmd.exe / /bin/sh. One of the interpolated values is the name field of the package.json sitting in the working directory.
src/cli/commands/deploy.ts:222 projectName = pkg.name.replace(/^@[^/]+\//, "") // scope strip only
src/cli/commands/deploy.ts:231 linkParts.push(`--project=${projectName}`)
src/cli/commands/deploy.ts:236 const linkCmd = linkParts.join(" ")
src/cli/commands/deploy.ts:244 execCmd(linkCmd, { inheritStderr: true })
src/cli/commands/deploy.ts:32 return execSync(cmd, { encoding: "utf-8", input: options?.input, stdio })
The only transformation applied to pkg.name is stripping an npm scope. &, |, ;, backticks and $() all survive into a string the shell parses.
Steps to reproduce
- Put a
package.json in a directory with this name field (<marker> stands for any path the
injected command can write to):
{ "name": "@scope/tool & echo INJECTED > \"<marker>\" & rem", "version": "1.0.0" }
- Run
tool-sdk deploy --host vercel in that directory, logged in to Vercel.
- The
echo runs.
Proof of concept
The shipped construction code, run over that file, builds:
npx vercel link --yes --project=tool & echo INJECTED-VIA-PACKAGE-JSON > "<marker>" & rem
and executing it writes the marker:
*** COMMAND EXECUTION — the marker was written by the injected segment ***
INJECTED-VIA-PACKAGE-JSON
evidence/execcmd_injection.txt.
How the harness was kept safe, and what that costs the proof. The run replaces the leading npx vercel link with the inert builtin ver, so the Vercel CLI is never invoked and no project is linked. Everything after that first token — including the injected & — is byte-identical to what the shipped code builds. What is demonstrated is the shell's handling of the constructed string, not an end-to-end deploy.
One correction recorded because it nearly produced a false negative: the first attempt used rem as the inert command. rem comments out the rest of the line in cmd.exe, which swallowed the injected segment and printed "no execution". The instrument was wrong, not the target.
A second instance of the same construction, which is gated
deploy.ts:315 does execCmd(\npx vercel env add ${envVar.name} production`), and parseEnvExampletakes **everything left of the first=** on a line of .env.local.example` as the name, with no charset check. The string it builds is injectable in the same way. I am not claiming it as a working vector, because both paths to it are gated and I verified the gates:
--non-interactive (deploy.ts:284-293): value = process.env[envVar.name] ?? "", and a crafted name is never a real environment variable, so it hits process.exit(1) before execCmd.
- interactive (
deploy.ts:294-311): the crafted name is the prompt label the developer reads, and a non-empty value must be typed before the command runs.
It is reported as a second instance of the defective construction, not as a second exploit.
A third instance, also not claimed: vercelScope — the captured stdout of npx vercel whoami (deploy.ts:199) — is interpolated into --scope=${vercelScope} on the same line.
Impact, stated narrowly
Command execution as the developer, in the shell running deploy, with that shell's environment and its authenticated Vercel session.
I want to be precise about what that environment is, because two obvious overstatements are wrong
and I checked both.
deploy.ts does not read wallet credentials by name — PRIVATE_KEY, PRIVY_APP_SECRET,
TURNKEY_API_PRIVATE_KEY, FIREBLOCKS_API_SECRET and BANKR_API_KEY are read by auth.ts,
pay.ts, smoke.ts and by the @opensea/wallet-adapters dependency. But it does read the
environment: deploy.ts:285 evaluates process.env[envVar.name] where the name comes from the
attacker-authored .env.local.example, and pipes the value into the subprocess as stdin at
deploy.ts:315-318. So the injected command inherits the developer's shell environment, and a
crafted variable name additionally causes deploy to read a chosen environment variable.
I also checked whether those five names are simply absent from the shipped artifact, which would
have made the point stronger. They are not: only three are missing from dist/cli.js itself, and
@opensea/wallet-adapters — a direct dependency — reads all five. My first grep returned zero for
all of them because it searched the wrong path; a positive control on process.env (40 hits)
caught that.
The argument against this report, which I think triage should hear from me rather than find
Two arguments cut against this report and triage should hear both from me.
First, the documented route into such a directory installs first — README.md:14 is cd my-tool && npm install, and the examples use pnpm install --ignore-workspace (examples/nft-appraisal-tool/README.md:157, examples/wallet-personality-tool/README.md:132). An attacker who authored the package.json could add an install lifecycle script and get execution before deploy ever runs. I checked the templates: vercel, cloudflare and express ship no lifecycle scripts at all, so that route is not something the project already relies on — but it is available to an attacker.
Second, and more damaging: deploy.ts:344 is npx vercel deploy --prod. The command's whole purpose is to publish the working directory to the developer's own production Vercel account. Anyone who runs it in an attacker-authored directory is already shipping that attacker's code under their own name. Against that, the marginal impact of local command execution is real but narrow — it moves execution from Vercel's build sandbox to the developer's shell.
What is left after conceding that, and why I still think it is worth fixing:
- the defect is in
tool-sdk's own code, not in npm's threat model, and it is trivially fixable;
deploy can be run in a directory whose package.json was edited after install — a pulled branch, an agent-scaffolded change, a dependency-bot commit — without a fresh npm install;
--project= accepts the payload with no interaction at all, in both interactive and --non-interactive mode, which is a materially different bar from a lifecycle script a developer might inspect or disable with --ignore-scripts.
Weighing both, this is not a P3. I am reporting it at P4: a genuine, trivially fixable injection in the SDK's own code, whose realistic marginal impact over what the same workflow already concedes is small.
On the Bugcrowd brief, for completeness
The brief excludes "issues requiring control of the client environment or application integrating the SDK". A hostile package.json is arguably the integrating application's own manifest, and I would rather name that reading than have it found: if triage applies it, I accept the call. My argument the other way is that deploy chose to parse an untrusted file and hand its contents to a shell, and that is a defect in the SDK regardless of who wrote the file.
Suggested fix
execFileSync("npx", ["vercel", "link", "--yes", --project=${projectName}]) passes the value through argv, where & is a character rather than a separator, and the same change fixes all three sites. Validate pkg.name against the npm name grammar and env-var names against /^[A-Za-z_][A-Za-z0-9_]*$/ as well — argv alone does not help a value that begins with -.
Scope of testing
Entirely local: a throwaway directory under my own lab path, one file I wrote, and a marker file. No Vercel project was linked, no environment variable was set on any Vercel account, and no OpenSea service was contacted. An earlier version of this harness did invoke npx vercel link, which fetched the Vercel CLI and opened a login page; that run was aborted immediately, no credentials were entered, no account was linked, and the harness was rebuilt so it never invokes the Vercel CLI.
Reported against
@opensea/tool-sdk@0.28.5(npm latest) and present unchanged atmain. Filed as a public issue because private vulnerability reporting is not enabled on this repository and there is noSECURITY.md; enabling PVR would be worth doing.CWE-78, OS command injection. Severity: low — the reasoning is in “The arguments against this report” below, which I have written out rather than left for you to find.
deploybuilds a shell command string by interpolation and hands it toexecSync, which runs it throughcmd.exe//bin/sh. One of the interpolated values is thenamefield of thepackage.jsonsitting in the working directory.The only transformation applied to
pkg.nameis stripping an npm scope.&,|,;, backticks and$()all survive into a string the shell parses.Steps to reproduce
package.jsonin a directory with this name field (<marker>stands for any path theinjected command can write to):
{ "name": "@scope/tool & echo INJECTED > \"<marker>\" & rem", "version": "1.0.0" }tool-sdk deploy --host vercelin that directory, logged in to Vercel.echoruns.Proof of concept
The shipped construction code, run over that file, builds:
and executing it writes the marker:
evidence/execcmd_injection.txt.How the harness was kept safe, and what that costs the proof. The run replaces the leading
npx vercel linkwith the inert builtinver, so the Vercel CLI is never invoked and no project is linked. Everything after that first token — including the injected&— is byte-identical to what the shipped code builds. What is demonstrated is the shell's handling of the constructed string, not an end-to-enddeploy.One correction recorded because it nearly produced a false negative: the first attempt used
remas the inert command.remcomments out the rest of the line incmd.exe, which swallowed the injected segment and printed "no execution". The instrument was wrong, not the target.A second instance of the same construction, which is gated
deploy.ts:315doesexecCmd(\npx vercel env add ${envVar.name} production`), andparseEnvExampletakes **everything left of the first=** on a line of.env.local.example` as the name, with no charset check. The string it builds is injectable in the same way. I am not claiming it as a working vector, because both paths to it are gated and I verified the gates:--non-interactive(deploy.ts:284-293):value = process.env[envVar.name] ?? "", and a crafted name is never a real environment variable, so it hitsprocess.exit(1)beforeexecCmd.deploy.ts:294-311): the crafted name is the prompt label the developer reads, and a non-empty value must be typed before the command runs.It is reported as a second instance of the defective construction, not as a second exploit.
A third instance, also not claimed:
vercelScope— the captured stdout ofnpx vercel whoami(deploy.ts:199) — is interpolated into--scope=${vercelScope}on the same line.Impact, stated narrowly
Command execution as the developer, in the shell running
deploy, with that shell's environment and its authenticated Vercel session.I want to be precise about what that environment is, because two obvious overstatements are wrong
and I checked both.
deploy.tsdoes not read wallet credentials by name —PRIVATE_KEY,PRIVY_APP_SECRET,TURNKEY_API_PRIVATE_KEY,FIREBLOCKS_API_SECRETandBANKR_API_KEYare read byauth.ts,pay.ts,smoke.tsand by the@opensea/wallet-adaptersdependency. But it does read theenvironment:
deploy.ts:285evaluatesprocess.env[envVar.name]where the name comes from theattacker-authored
.env.local.example, and pipes the value into the subprocess as stdin atdeploy.ts:315-318. So the injected command inherits the developer's shell environment, and acrafted variable name additionally causes
deployto read a chosen environment variable.I also checked whether those five names are simply absent from the shipped artifact, which would
have made the point stronger. They are not: only three are missing from
dist/cli.jsitself, and@opensea/wallet-adapters— a direct dependency — reads all five. My first grep returned zero forall of them because it searched the wrong path; a positive control on
process.env(40 hits)caught that.
The argument against this report, which I think triage should hear from me rather than find
Two arguments cut against this report and triage should hear both from me.
First, the documented route into such a directory installs first —
README.md:14iscd my-tool && npm install, and the examples usepnpm install --ignore-workspace(examples/nft-appraisal-tool/README.md:157,examples/wallet-personality-tool/README.md:132). An attacker who authored thepackage.jsoncould add an install lifecycle script and get execution beforedeployever runs. I checked the templates:vercel,cloudflareandexpressship no lifecycle scripts at all, so that route is not something the project already relies on — but it is available to an attacker.Second, and more damaging:
deploy.ts:344isnpx vercel deploy --prod. The command's whole purpose is to publish the working directory to the developer's own production Vercel account. Anyone who runs it in an attacker-authored directory is already shipping that attacker's code under their own name. Against that, the marginal impact of local command execution is real but narrow — it moves execution from Vercel's build sandbox to the developer's shell.What is left after conceding that, and why I still think it is worth fixing:
tool-sdk's own code, not in npm's threat model, and it is trivially fixable;deploycan be run in a directory whosepackage.jsonwas edited after install — a pulled branch, an agent-scaffolded change, a dependency-bot commit — without a freshnpm install;--project=accepts the payload with no interaction at all, in both interactive and--non-interactivemode, which is a materially different bar from a lifecycle script a developer might inspect or disable with--ignore-scripts.Weighing both, this is not a P3. I am reporting it at P4: a genuine, trivially fixable injection in the SDK's own code, whose realistic marginal impact over what the same workflow already concedes is small.
On the Bugcrowd brief, for completeness
The brief excludes "issues requiring control of the client environment or application integrating the SDK". A hostile
package.jsonis arguably the integrating application's own manifest, and I would rather name that reading than have it found: if triage applies it, I accept the call. My argument the other way is thatdeploychose to parse an untrusted file and hand its contents to a shell, and that is a defect in the SDK regardless of who wrote the file.Suggested fix
execFileSync("npx", ["vercel", "link", "--yes",--project=${projectName}])passes the value through argv, where&is a character rather than a separator, and the same change fixes all three sites. Validatepkg.nameagainst the npm name grammar and env-var names against/^[A-Za-z_][A-Za-z0-9_]*$/as well — argv alone does not help a value that begins with-.Scope of testing
Entirely local: a throwaway directory under my own lab path, one file I wrote, and a marker file. No Vercel project was linked, no environment variable was set on any Vercel account, and no OpenSea service was contacted. An earlier version of this harness did invoke
npx vercel link, which fetched the Vercel CLI and opened a login page; that run was aborted immediately, no credentials were entered, no account was linked, and the harness was rebuilt so it never invokes the Vercel CLI.