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:
parent
7e19f9361c
commit
90c2e69d51
1 changed files with 44 additions and 11 deletions
|
|
@ -1,10 +1,27 @@
|
||||||
import Fastify from 'fastify'
|
import Fastify from 'fastify'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { exec, execSync } from 'child_process'
|
import { exec, spawn } from 'child_process'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
|
|
||||||
const execAsync = promisify(exec)
|
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 })
|
const app = Fastify({ logger: true })
|
||||||
|
|
||||||
// Capture the exact request bytes for webhook HMAC verification —
|
// 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))
|
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sshGet(host, cmd) {
|
async function sshGet(host, cmdArgs) {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(
|
const { stdout } = await spawnAsync('ssh', [
|
||||||
`ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no -o ConnectTimeout=2 root@${host} "${cmd}"`,
|
'-i', SSH_KEY,
|
||||||
{ timeout: 8_000 }
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
)
|
'-o', 'ConnectTimeout=2',
|
||||||
|
`root@${host}`,
|
||||||
|
...cmdArgs,
|
||||||
|
], { timeout: 8_000 })
|
||||||
return stdout.trim()
|
return stdout.trim()
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
|
|
@ -119,9 +139,13 @@ async function getForgejoCommit(repo) {
|
||||||
async function refreshStatus(repo) {
|
async function refreshStatus(repo) {
|
||||||
const target = getDeployTarget(repo)
|
const target = getDeployTarget(repo)
|
||||||
if (!target) return null
|
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([
|
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),
|
getForgejoCommit(repo),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
@ -133,7 +157,7 @@ async function refreshStatus(repo) {
|
||||||
const entry = {
|
const entry = {
|
||||||
repo,
|
repo,
|
||||||
host: target.host,
|
host: target.host,
|
||||||
currentCommit: deployed ? currentSha.slice(0, 12) : (currentRaw || 'unreachable'),
|
currentCommit: deployed ? currentSha.slice(0, 12) : 'not-deployed',
|
||||||
currentCommitAt: currentTimestamp || null,
|
currentCommitAt: currentTimestamp || null,
|
||||||
latestCommit: latest ? latest.sha.slice(0, 12) : 'unknown',
|
latestCommit: latest ? latest.sha.slice(0, 12) : 'unknown',
|
||||||
latestCommitAt: latest?.timestamp || null,
|
latestCommitAt: latest?.timestamp || null,
|
||||||
|
|
@ -146,15 +170,24 @@ async function refreshStatus(repo) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deploy(target, repoName) {
|
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' }
|
const entry = { repo: repoName, host: target.host, started: new Date().toISOString(), status: 'running' }
|
||||||
deployLog.unshift(entry)
|
deployLog.unshift(entry)
|
||||||
if (deployLog.length > 50) deployLog.pop()
|
if (deployLog.length > 50) deployLog.pop()
|
||||||
|
|
||||||
try {
|
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.status = 'success'
|
||||||
entry.output = out.slice(-500)
|
entry.output = stdout.slice(-500)
|
||||||
app.log.info(`Deploy ${repoName} → success`)
|
app.log.info(`Deploy ${repoName} → success`)
|
||||||
delete statusCache[repoName]
|
delete statusCache[repoName]
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue