fix(security): validate script templates before base64 encoding (#3132)

Add pre-encoding validation to reject ${} interpolation patterns in
script template strings before they are base64-encoded and injected
into systemd services running with root privileges on remote VMs.

Defense-in-depth against future regressions where template variable
interpolation before encoding could allow command injection.

Fixes #3130

Agent: security-auditor

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
A 2026-03-31 20:15:20 -07:00 committed by GitHub
parent 9895d6e8cc
commit 3b61c22f25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 32 additions and 2 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.30.1",
"version": "0.30.2",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -42,6 +42,27 @@ export interface CloudRunner {
downloadFile(remotePath: string, localPath: string): Promise<void>;
}
// ─── Script template validation ────────────────────────────────────────────
/**
* Validate that a script template string does not contain JS template
* interpolation patterns (`${...}`) before it is base64-encoded for shell
* injection into systemd units or remote commands.
*
* Defense-in-depth: the scripts are currently static string arrays joined
* with `\n`, so they should never contain interpolation markers. This guard
* catches future regressions where a developer might accidentally introduce
* template literal interpolation before encoding.
*
* Note: backticks alone are allowed (used in markdown content for skill
* files), but `${` is always rejected as it indicates JS interpolation.
*/
export function validateScriptTemplate(script: string, label: string): void {
if (/\$\{/.test(script)) {
throw new Error(`Script template "${label}" contains \${} interpolation — refusing to encode`);
}
}
// ─── Install helpers ────────────────────────────────────────────────────────
async function installAgent(
@ -550,6 +571,9 @@ export async function startGateway(runner: CloudRunner): Promise<void> {
"WantedBy=multi-user.target",
].join("\n");
validateScriptTemplate(wrapperScript, "gateway-wrapper");
validateScriptTemplate(unitFile, "gateway-unit");
const wrapperB64 = Buffer.from(wrapperScript).toString("base64");
const unitB64 = Buffer.from(unitFile).toString("base64");
if (!/^[A-Za-z0-9+/=]+$/.test(wrapperB64)) {
@ -811,6 +835,10 @@ export async function setupAutoUpdate(runner: CloudRunner, agentName: string, up
"WantedBy=timers.target",
].join("\n");
validateScriptTemplate(wrapperScript, "auto-update-wrapper");
validateScriptTemplate(unitFile, "auto-update-unit");
validateScriptTemplate(timerFile, "auto-update-timer");
const wrapperB64 = Buffer.from(wrapperScript).toString("base64");
const unitB64 = Buffer.from(unitFile).toString("base64");
const timerB64 = Buffer.from(timerFile).toString("base64");

View file

@ -4,7 +4,7 @@
import type { CloudRunner } from "./agent-setup.js";
import { wrapSshCall } from "./agent-setup.js";
import { validateScriptTemplate, wrapSshCall } from "./agent-setup.js";
import { asyncTryCatchIf, isOperationalError } from "./result.js";
import { logInfo, logWarn } from "./ui.js";
@ -158,6 +158,8 @@ export async function injectSpawnSkill(runner: CloudRunner, agentName: string):
return;
}
validateScriptTemplate(config.content, `spawn-skill-${agentName}`);
const b64 = Buffer.from(config.content).toString("base64");
if (!/^[A-Za-z0-9+/=]+$/.test(b64)) {
throw new Error("Unexpected characters in base64 output");