Two real gotchas from today's goddy->gringo Laravel migration: every jail on an NPM-fronted host needs a dedicated listen port (8000+last-octet), not just the shared port 80, or NPM cutover silently can't reach it even though every direct/Host-header test passes. Separately, zsh ties a variable literally named `path` to $PATH itself - overwriting it broke every subsequent command in a config-editing script with no clear error. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
4.4 KiB
name, description
| name | description |
|---|---|
| remote-shell-quoting-safety | Use when a command or file content needs to pass through multiple nested shell layers (e.g. local shell -> ssh -> a jail/container exec wrapper -> su -> the target interpreter). Avoids silent corruption of $, quotes, and special characters. |
Remote Shell Quoting Safety
The problem
Content containing $, quotes, or other shell-special characters gets
progressively mangled the more shell layers it passes through (local
shell → ssh → a container/jail exec wrapper like bastille cmd →
su -c → the actual target interpreter). Each layer's own quoting rules
interact, and by 3+ layers deep it becomes extremely hard to reason about
correctly — and failures are often silent: no error, just corrupted
content (e.g. $_SERVER['KEY'] silently becoming ['KEY'], $ and the
variable name simply eaten).
Confirmed real incident: a wp-config.php snippet containing
$_SERVER['HTTP_X_FORWARDED_PROTO'] passed through
ssh → bastille cmd → su -m www -c '...' came out the other side as
bare ["HTTP_X_FORWARDED_PROTO"] — no error at the point of corruption,
the resulting fatal PHP error only surfaced two steps later at a
completely different command, making the actual cause much harder to
trace back.
The fix: base64, not nested quoting
Don't try to escape correctly through N layers. Instead:
- Write the target file's complete final content locally (a real file, not a shell variable).
- Base64-encode it locally:
base64 -w0 localfile > /tmp/payload.b64(or pipe directly). cat/pipe the base64 text through the SSH/exec layers (base64 output is alphanumeric-only — nothing for any shell layer to misinterpret, regardless of how many layers it passes through).- Decode back to the real file on the far side, in ONE final step:
base64 -d payload.b64 > /real/target/path.
B64=$(base64 -w0 local_file.php)
echo "$B64" | ssh user@host "cat > /tmp/upload.b64"
ssh user@host "base64 -d /tmp/upload.b64 > /real/target/path && rm /tmp/upload.b64"
For a jail/container layer on top of SSH (e.g. bastille cmd <jail>),
land the base64 payload on the host filesystem first (which the jail
can usually see via a shared mount or a copy step), then decode from
inside the jail in one command — don't try to thread the base64
string itself through the jail-exec wrapper's own quoting too; one
decode step per real filesystem boundary crossed is enough.
When to reach for this
- Any file content containing
$, backticks, mixed quotes, or PHP/shell code being pushed through 2+ nested shell contexts. - Whenever a "the file exists but does something subtly wrong" bug appears after a multi-hop deployment step, and the content involves special characters — check for quoting corruption before looking elsewhere.
zsh footgun: never name a variable path (or a few other reserved names)
If the remote host's shell is zsh (several boxes in this fleet default to
it), assigning a plain string to a variable literally named path does
not create a normal local variable — zsh ties the lowercase path array
to $PATH (colon-joined ⇄ array elements) automatically. Overwriting it
with a single string replaces $PATH itself, and every subsequent
command in that shell (including ones on later lines of the same script)
fails with command not found: cp / command not found: awk / etc. —
confirmed live 2026-08-07 editing nginx configs over SSH into a zsh
remote shell, where a loop using path="/usr/local/etc/nginx/..." broke
every command after the assignment with no explanation beyond
command not found. Rename to anything else (cfgpath, target,
dest, ...) — this isn't specific to nginx configs, it'll happen with
any script that happens to pick path as a variable name on a zsh
remote. A few other zsh-reserved lowercase names exist for the same
reason (status, pipestatus) — when in doubt, avoid single common
English words as variable names in scripts meant to run under an
unknown/remote shell, or check typeset -p <name> first if unsure.
What NOT to do
Don't respond to a quoting failure by adding more layers of escaping
(\\\$, '"'"', etc.) — this usually makes the problem harder to
reason about, not easier, and is exactly the kind of fragile fix that
looks like it works until the next slightly-different payload breaks it
again. Switch to the base64 pattern instead of debugging escape depth.