Skip to content

Commit 2aba8e6

Browse files
louisgvclaude
andcommitted
fix(security): expand $HOME before path validation in downloadFile
Fixes #3080 Prevents path traversal via other $VAR expansions by normalizing $HOME to ~ before the strict path regex check, removing the need to allow $ in the charset. Applied to all 5 cloud providers: - digitalocean: downloadFile - aws: downloadFile - sprite: downloadFileSprite - gcp: uploadFile + downloadFile - hetzner: downloadFile Also bumps CLI version to 0.27.7. Agent: security-auditor Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ccd8600 commit 2aba8e6

6 files changed

Lines changed: 19 additions & 20 deletions

File tree

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openrouter/spawn",
3-
"version": "0.27.6",
3+
"version": "0.27.7",
44
"type": "module",
55
"bin": {
66
"spawn": "cli.js"

packages/cli/src/aws/aws.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,15 +1178,15 @@ export async function uploadFile(localPath: string, remotePath: string): Promise
11781178
}
11791179

11801180
export async function downloadFile(remotePath: string, localPath: string): Promise<void> {
1181-
const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~$-]+$/);
1181+
const expandedRemote = remotePath.replace(/^\$HOME\//, "~/");
1182+
const normalizedRemote = validateRemotePath(expandedRemote, /^[a-zA-Z0-9/_.~-]+$/);
11821183
const keyOpts = getSshKeyOpts(await ensureSshKeys());
1183-
const expandedPath = normalizedRemote.replace(/^\$HOME/, "~");
11841184
const proc = Bun.spawn(
11851185
[
11861186
"scp",
11871187
...SSH_BASE_OPTS,
11881188
...keyOpts,
1189-
`${SSH_USER}@${_state.instanceIp}:${expandedPath}`,
1189+
`${SSH_USER}@${_state.instanceIp}:${normalizedRemote}`,
11901190
localPath,
11911191
],
11921192
{

packages/cli/src/digitalocean/digitalocean.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,17 +1448,17 @@ export async function uploadFile(localPath: string, remotePath: string, ip?: str
14481448

14491449
export async function downloadFile(remotePath: string, localPath: string, ip?: string): Promise<void> {
14501450
const serverIp = ip || _state.serverIp;
1451-
const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~$-]+$/);
1451+
const expandedRemote = remotePath.replace(/^\$HOME\//, "~/");
1452+
const normalizedRemote = validateRemotePath(expandedRemote, /^[a-zA-Z0-9/_.~-]+$/);
14521453

14531454
const keyOpts = getSshKeyOpts(await ensureSshKeys());
1454-
const expandedPath = normalizedRemote.replace(/^\$HOME/, "~");
14551455

14561456
const proc = Bun.spawn(
14571457
[
14581458
"scp",
14591459
...SSH_BASE_OPTS,
14601460
...keyOpts,
1461-
`root@${serverIp}:${expandedPath}`,
1461+
`root@${serverIp}:${normalizedRemote}`,
14621462
localPath,
14631463
],
14641464
{

packages/cli/src/gcp/gcp.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,10 +1028,9 @@ export async function uploadFile(localPath: string, remotePath: string): Promise
10281028
logError(`Invalid local path: ${localPath}`);
10291029
throw new Error("Invalid local path");
10301030
}
1031-
const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~$-]+$/);
1031+
const expandedRemote = remotePath.replace(/^\$HOME\//, "~/");
1032+
const normalizedRemote = validateRemotePath(expandedRemote, /^[a-zA-Z0-9/_.~-]+$/);
10321033
const username = resolveUsername();
1033-
// Expand $HOME on remote side
1034-
const expandedPath = normalizedRemote.replace(/^\$HOME/, "~");
10351034
const keyOpts = getSshKeyOpts(await ensureSshKeys());
10361035

10371036
const proc = Bun.spawn(
@@ -1040,7 +1039,7 @@ export async function uploadFile(localPath: string, remotePath: string): Promise
10401039
...SSH_BASE_OPTS,
10411040
...keyOpts,
10421041
localPath,
1043-
`${username}@${_state.serverIp}:${expandedPath}`,
1042+
`${username}@${_state.serverIp}:${normalizedRemote}`,
10441043
],
10451044
{
10461045
stdio: [
@@ -1067,17 +1066,17 @@ export async function downloadFile(remotePath: string, localPath: string): Promi
10671066
logError(`Invalid local path: ${localPath}`);
10681067
throw new Error("Invalid local path");
10691068
}
1070-
const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~$-]+$/);
1069+
const expandedRemote = remotePath.replace(/^\$HOME\//, "~/");
1070+
const normalizedRemote = validateRemotePath(expandedRemote, /^[a-zA-Z0-9/_.~-]+$/);
10711071
const username = resolveUsername();
1072-
const expandedPath = normalizedRemote.replace(/^\$HOME/, "~");
10731072
const keyOpts = getSshKeyOpts(await ensureSshKeys());
10741073

10751074
const proc = Bun.spawn(
10761075
[
10771076
"scp",
10781077
...SSH_BASE_OPTS,
10791078
...keyOpts,
1080-
`${username}@${_state.serverIp}:${expandedPath}`,
1079+
`${username}@${_state.serverIp}:${normalizedRemote}`,
10811080
localPath,
10821081
],
10831082
{

packages/cli/src/hetzner/hetzner.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -909,17 +909,17 @@ export async function uploadFile(localPath: string, remotePath: string, ip?: str
909909

910910
export async function downloadFile(remotePath: string, localPath: string, ip?: string): Promise<void> {
911911
const serverIp = ip || _state.serverIp;
912-
const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~$-]+$/);
912+
const expandedRemote = remotePath.replace(/^\$HOME\//, "~/");
913+
const normalizedRemote = validateRemotePath(expandedRemote, /^[a-zA-Z0-9/_.~-]+$/);
913914

914915
const keyOpts = getSshKeyOpts(await ensureSshKeys());
915-
const expandedPath = normalizedRemote.replace(/^\$HOME/, "~");
916916

917917
const proc = Bun.spawn(
918918
[
919919
"scp",
920920
...SSH_BASE_OPTS,
921921
...keyOpts,
922-
`root@${serverIp}:${expandedPath}`,
922+
`root@${serverIp}:${normalizedRemote}`,
923923
localPath,
924924
],
925925
{

packages/cli/src/sprite/sprite.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -657,10 +657,10 @@ export async function uploadFileSprite(localPath: string, remotePath: string): P
657657

658658
/** Download a file from the remote sprite by catting it to stdout. */
659659
export async function downloadFileSprite(remotePath: string, localPath: string): Promise<void> {
660-
const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~$-]+$/);
660+
const expandedRemote = remotePath.replace(/^\$HOME\//, "~/");
661+
const normalizedRemote = validateRemotePath(expandedRemote, /^[a-zA-Z0-9/_.~-]+$/);
661662

662663
const spriteCmd = getSpriteCmd()!;
663-
const expandedPath = normalizedRemote.replace(/^\$HOME/, "~");
664664

665665
await spriteRetry("sprite download", async () => {
666666
const proc = Bun.spawn(
@@ -672,7 +672,7 @@ export async function downloadFileSprite(remotePath: string, localPath: string):
672672
_state.name,
673673
"--",
674674
"cat",
675-
expandedPath,
675+
normalizedRemote,
676676
],
677677
{
678678
stdio: [

0 commit comments

Comments
 (0)