Skip to content

Commit 03ed0b1

Browse files
authored
fix security risks with postinstall curl-ing binary (#239)
* fix * harden js based postinstall.js * stop using local-postinstall in e2e * better logs * deprecate the old installer * switch to adm-zip * remove WARNING: * safeUnlink * fix warning * remove try catch
1 parent 81f3009 commit 03ed0b1

File tree

8 files changed

+229
-19
lines changed

8 files changed

+229
-19
lines changed

.github/workflows/e2e.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ jobs:
4343
./sqlx-ts --help
4444
4545
- name: Install using local install.sh
46-
run: node local-postinstall.js
46+
run: node postinstall.js
4747

4848
- name: Verify sqlx-ts binary from local install
4949
run: |

.github/workflows/release.yaml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,22 @@ jobs:
9292
7z a "$DIRECTORY.zip" "$DIRECTORY"
9393
echo "ASSET=$DIRECTORY.zip" >> $GITHUB_ENV
9494
95+
- name: Generate SHA-256 checksum
96+
shell: bash
97+
run: |
98+
if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
99+
certutil -hashfile "${{ env.ASSET }}" SHA256 | findstr /v "hash" | findstr /v "CertUtil" > "${{ env.ASSET }}.sha256"
100+
else
101+
shasum -a 256 "${{ env.ASSET }}" | awk '{print $1}' > "${{ env.ASSET }}.sha256"
102+
fi
103+
cat "${{ env.ASSET }}.sha256"
104+
95105
- name: Upload release archive
96106
uses: softprops/action-gh-release@v1
97107
with:
98108
files: |
99-
${{ env.ASSET }}
109+
${{ env.ASSET }}
110+
${{ env.ASSET }}.sha256
100111
101112
node:
102113
needs: assets

node/.eslintrc.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
module.exports = {
22
"env": {
3-
"browser": true,
3+
"browser": false,
4+
"node": true,
45
"es2021": true,
56
},
67
"extends": [
@@ -15,5 +16,7 @@ module.exports = {
1516
"plugins": [
1617
"@typescript-eslint",
1718
],
18-
"rules": {},
19+
"rules": {
20+
"@typescript-eslint/no-var-requires": "off"
21+
},
1922
};

node/local-postinstall.js

Lines changed: 0 additions & 10 deletions
This file was deleted.

node/package-lock.json

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

node/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
"test": "npx jest",
1818
"prepublishOnly": "cp ../README.md . && npm i && npm run compile"
1919
},
20+
"dependencies": {
21+
"adm-zip": "^0.5.16"
22+
},
2023
"devDependencies": {
2124
"@types/jest": "^27.4.1",
2225
"@types/node": "^20.14.8",

node/postinstall.js

Lines changed: 191 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,194 @@
1-
let execSync = require('child_process').execSync
2-
let tag = require('./package.json').version
1+
const { createHash } = require('crypto')
2+
const fs = require('fs')
3+
const https = require('https')
4+
const path = require('path')
5+
const os = require('os')
6+
const tag = require('./package.json').version
7+
const AdmZip = require('adm-zip')
38

4-
const os = process.platform
9+
const platform = process.platform
510
const cpu = process.arch
611

7-
execSync(`curl -LSfs https://jasonshin.github.io/sqlx-ts/install.sh | sh -s -- --os ${os} --cpu ${cpu} --tag ${tag} -f`, { stdio: 'inherit' })
8-
console.info('sqlx-ts installation successful')
12+
const colors = {
13+
reset: "\x1b[0m",
14+
red: "\x1b[31m",
15+
green: "\x1b[32m",
16+
yellow: "\x1b[33m",
17+
cyan: "\x1b[36m",
18+
}
19+
20+
const info = (msg) => console.log(`${colors.cyan}INFO: ${msg} ${colors.reset}`)
21+
const success = (msg) => console.log(`${colors.green}SUCCESS: ${msg} ${colors.reset}`)
22+
const warn = (msg) => console.warn(`${colors.yellow}WARNING: ${msg} ${colors.reset}`)
23+
const error = (msg) => console.error(`${colors.red}ERROR: ${msg} ${colors.reset}`)
24+
25+
function getBinaryInfo() {
26+
let build = ''
27+
if (platform === 'darwin') {
28+
if (cpu === 'arm64') {
29+
build = 'macos-arm'
30+
} else {
31+
build = 'macos-64-bit'
32+
}
33+
} else if (platform === 'win32') {
34+
if (cpu === 'x64') {
35+
build = 'windows-64-bit'
36+
} else {
37+
build = 'windows-32-bit'
38+
}
39+
} else if (platform === 'linux') {
40+
if (cpu === 'x64') {
41+
build = 'linux-64-bit'
42+
} else if (cpu === 'arm64') {
43+
build = 'linux-arm'
44+
} else {
45+
build = 'linux-32-bit'
46+
}
47+
} else {
48+
throw new Error(`Unsupported platform: ${platform}-${cpu}`)
49+
}
50+
51+
return {
52+
build,
53+
filename: `sqlx-ts-v${tag}-${build}.zip`,
54+
binaryName: platform === 'win32' ? 'sqlx-ts.exe' : 'sqlx-ts'
55+
}
56+
}
57+
58+
function safeUnlink(filePath) {
59+
try {
60+
if (fs.existsSync(filePath)) {
61+
fs.unlinkSync(filePath)
62+
}
63+
} catch (err) {
64+
warn(`Failed to delete file: filePath: ${filePath}, err: ${err.message}`)
65+
}
66+
}
67+
68+
// Download file from URL
69+
function downloadFile(url, destination, redirectCount = 0) {
70+
return new Promise((resolve, reject) => {
71+
if (redirectCount > 5) {
72+
return reject(new Error("Too many redirects while downloading file"))
73+
}
74+
75+
const file = fs.createWriteStream(destination)
76+
https.get(url, (response) => {
77+
if (response.statusCode === 302 || response.statusCode === 301) {
78+
// Handle redirects
79+
// Close the current file and delete it
80+
// Then download from the new location
81+
file.close()
82+
safeUnlink(destination)
83+
return downloadFile(response.headers.location, destination, redirectCount + 1)
84+
.then(resolve)
85+
.catch(reject)
86+
}
87+
88+
if (response.statusCode !== 200) {
89+
file.close()
90+
safeUnlink(destination)
91+
return reject(new Error(`Failed to download: ${response.statusCode} ${response.statusMessage}`))
92+
}
93+
94+
response.pipe(file)
95+
file.on('finish', () => {
96+
file.close(resolve)
97+
})
98+
}).on('error', (err) => {
99+
safeUnlink(destination)
100+
reject(err)
101+
})
102+
})
103+
}
104+
105+
// Calculate SHA-256 hash of a file
106+
function calculateSHA256(filePath) {
107+
return new Promise((resolve, reject) => {
108+
const hash = createHash('sha256')
109+
const stream = fs.createReadStream(filePath)
110+
111+
stream.on('data', (data) => hash.update(data))
112+
stream.on('end', () => resolve(hash.digest('hex')))
113+
stream.on('error', reject)
114+
})
115+
}
116+
117+
async function verifyHash(filePath, expectedHash) {
118+
const actualHash = await calculateSHA256(filePath)
119+
120+
if (actualHash !== expectedHash) {
121+
throw new Error(
122+
`Hash mismatch!\n` +
123+
`Expected: ${expectedHash}\n` +
124+
`Got: ${actualHash}\n` +
125+
`This could indicate a corrupted download or a security issue.`
126+
)
127+
}
128+
129+
return true
130+
}
131+
132+
function extractBinary(zipPath, binaryName, targetPath) {
133+
const zip = new AdmZip(zipPath)
134+
const zipEntries = zip.getEntries()
135+
136+
for (const entry of zipEntries) {
137+
if (entry.entryName.endsWith(binaryName)) {
138+
// Extract the entry's content directly
139+
const data = entry.getData()
140+
fs.writeFileSync(targetPath, data)
141+
fs.chmodSync(targetPath, 0o755)
142+
return
143+
}
144+
}
145+
146+
throw new Error(`Binary ${binaryName} not found in archive`)
147+
}
148+
149+
async function install() {
150+
try {
151+
const { filename, binaryName } = getBinaryInfo()
152+
const baseUrl = `https://github.com/JasonShin/sqlx-ts/releases/download/v${tag}`
153+
const zipUrl = `${baseUrl}/${filename}`
154+
const checksumUrl = `${zipUrl}.sha256`
155+
156+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sqlx-ts-'))
157+
const zipPath = path.join(tmpDir, filename)
158+
const checksumPath = path.join(tmpDir, `${filename}.sha256`)
159+
const targetPath = path.join(__dirname, 'sqlx-ts' + (platform === 'win32' ? '.exe' : ''))
160+
161+
info(`Downloading sqlx-ts v${tag} for ${platform}-${cpu}...`)
162+
info(`URL: ${zipUrl}`)
163+
164+
// Download the zip file
165+
await downloadFile(zipUrl, zipPath)
166+
success('Download complete')
167+
168+
// Download and verify the checksum
169+
info('Downloading checksum...')
170+
await downloadFile(checksumUrl, checksumPath)
171+
const expectedHash = fs.readFileSync(checksumPath, 'utf8').trim()
172+
info(`Expected SHA-256: ${expectedHash}`)
173+
174+
// Verify the hash
175+
info('Verifying checksum...')
176+
await verifyHash(zipPath, expectedHash)
177+
success('Checksum verified successfully')
178+
179+
// Extract the binary
180+
info('Extracting binary...')
181+
extractBinary(zipPath, binaryName, targetPath)
182+
183+
// Cleanup
184+
fs.rmSync(tmpDir, { recursive: true, force: true })
185+
186+
info('sqlx-ts installation successful')
187+
process.exit(0)
188+
} catch (err) {
189+
error(`Installation failed: ${err.message}`)
190+
process.exit(1)
191+
}
192+
}
193+
194+
install()

scripts/install.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
set -e
44

5+
echo "⚠️ This install.sh script is deprecated and will be removed in a future release."
6+
echo " Please use the Node.js installer instead:"
7+
echo " npm install -g sqlx-ts"
8+
echo ""
9+
510
help() {
611
cat <<'EOF'
712
Install a binary release of a Rust crate hosted on GitHub

0 commit comments

Comments
 (0)