Replace execSync/exec shell interpolation with spawn arg arrays in updater

sshGet() and deploy() built SSH commands via template literal string
interpolation, which would allow shell injection if host/path values
from the app registry were tampered with. Replaced with spawnAsync()
using shell: false and explicit argv arrays. Added path validation
guard (/^\/opt\/[a-z0-9-]+$/) before both SSH calls. Also removes
the event-loop-blocking execSync in deploy().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-13 09:25:09 +00:00
parent 7e19f9361c
commit 90c2e69d51

View file

@ -1,10 +1,27 @@
import Fastify from 'fastify'
import crypto from 'crypto'
import { exec, execSync } from 'child_process'
import { exec, spawn } from 'child_process'
import { promisify } from 'util'
const execAsync = promisify(exec)
function spawnAsync(file, args, opts = {}) {
return new Promise((resolve, reject) => {
const child = spawn(file, args, { ...opts, shell: false })
let stdout = '', stderr = ''
child.stdout?.on('data', d => { stdout += d })
child.stderr?.on('data', d => { stderr += d })
child.on('error', reject)
child.on('close', code => {
if (code === 0) resolve({ stdout, stderr })
else reject(new Error(`Exit ${code}: ${stderr.slice(-500)}`))
})
if (opts.timeout) {
setTimeout(() => { child.kill(); reject(new Error('Timeout')) }, opts.timeout)
}
})
}
const app = Fastify({ logger: true })
// Capture the exact request bytes for webhook HMAC verification —
@ -89,12 +106,15 @@ function verifySignature(body, signature) {
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided))
}
async function sshGet(host, cmd) {
async function sshGet(host, cmdArgs) {
try {
const { stdout } = await execAsync(
`ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no -o ConnectTimeout=2 root@${host} "${cmd}"`,
{ timeout: 8_000 }
)
const { stdout } = await spawnAsync('ssh', [
'-i', SSH_KEY,
'-o', 'StrictHostKeyChecking=no',
'-o', 'ConnectTimeout=2',
`root@${host}`,
...cmdArgs,
], { timeout: 8_000 })
return stdout.trim()
} catch {
return null
@ -119,9 +139,13 @@ async function getForgejoCommit(repo) {
async function refreshStatus(repo) {
const target = getDeployTarget(repo)
if (!target) return null
if (!/^\/opt\/[a-z0-9-]+$/.test(target.path)) {
app.log.error(`Rejecting suspicious path: ${target.path}`)
return null
}
const [currentRaw, latest] = await Promise.all([
sshGet(target.host, `git -C ${target.path} log -1 --format='%H|%cI' HEAD 2>/dev/null || echo not-deployed`),
sshGet(target.host, ['git', '-C', target.path, 'log', '-1', '--format=%H|%cI', 'HEAD']),
getForgejoCommit(repo),
])
@ -133,7 +157,7 @@ async function refreshStatus(repo) {
const entry = {
repo,
host: target.host,
currentCommit: deployed ? currentSha.slice(0, 12) : (currentRaw || 'unreachable'),
currentCommit: deployed ? currentSha.slice(0, 12) : 'not-deployed',
currentCommitAt: currentTimestamp || null,
latestCommit: latest ? latest.sha.slice(0, 12) : 'unknown',
latestCommitAt: latest?.timestamp || null,
@ -146,15 +170,24 @@ async function refreshStatus(repo) {
}
async function deploy(target, repoName) {
const cmd = `ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no root@${target.host} "cd ${target.path} && git pull && docker compose up -d --build"`
if (!/^\/opt\/[a-z0-9-]+$/.test(target.path)) {
app.log.error(`Rejecting suspicious path for deploy: ${target.path}`)
return
}
const remoteCmd = `cd ${target.path} && git pull && docker compose up -d --build`
const entry = { repo: repoName, host: target.host, started: new Date().toISOString(), status: 'running' }
deployLog.unshift(entry)
if (deployLog.length > 50) deployLog.pop()
try {
const out = execSync(cmd, { timeout: 300_000, encoding: 'utf8' })
const { stdout } = await spawnAsync('ssh', [
'-i', SSH_KEY,
'-o', 'StrictHostKeyChecking=no',
`root@${target.host}`,
'sh', '-c', remoteCmd,
], { timeout: 300_000 })
entry.status = 'success'
entry.output = out.slice(-500)
entry.output = stdout.slice(-500)
app.log.info(`Deploy ${repoName} → success`)
delete statusCache[repoName]
} catch (err) {