| 164 |
164 |
|
total
|
| 165 |
165 |
|
}
|
| 166 |
166 |
|
|
| 167 |
|
- |
/// Single-quote a string for safe inclusion in a `/bin/sh` command, escaping
|
| 168 |
|
- |
/// any embedded single quote. Not bulletproof for adversarial input, but every
|
| 169 |
|
- |
/// path here comes from our own config files.
|
|
167 |
+ |
/// Single-quote a string for safe inclusion in a `/bin/sh` command. This is
|
|
168 |
+ |
/// complete POSIX single-quoting: the result is a single-quoted literal with
|
|
169 |
+ |
/// every embedded `'` rewritten as `'\''` (close-quote, escaped quote,
|
|
170 |
+ |
/// reopen-quote). Inside single quotes the shell treats every other byte —
|
|
171 |
+ |
/// `$`, backtick, `;`, `\`, newline — literally, so no metacharacter can act
|
|
172 |
+ |
/// and there is no way to break out. Proven adversarially by
|
|
173 |
+ |
/// `sh_quote_neutralizes_injection_through_real_sh`.
|
| 170 |
174 |
|
pub fn sh_quote(s: &str) -> String {
|
| 171 |
175 |
|
let escaped = s.replace('\'', r"'\''");
|
| 172 |
176 |
|
format!("'{escaped}'")
|
| 208 |
212 |
|
assert_eq!(sh_quote("it's"), r"'it'\''s'");
|
| 209 |
213 |
|
}
|
| 210 |
214 |
|
|
|
215 |
+ |
/// Adversarial proof: run a crafted payload through a real `/bin/sh` and
|
|
216 |
+ |
/// confirm it round-trips verbatim — i.e. the shell treated it as a literal
|
|
217 |
+ |
/// and no expansion, substitution, or command injection occurred.
|
|
218 |
+ |
#[tokio::test]
|
|
219 |
+ |
async fn sh_quote_neutralizes_injection_through_real_sh() {
|
|
220 |
+ |
use tokio::process::Command;
|
|
221 |
+ |
let payloads = [
|
|
222 |
+ |
"v'; touch /tmp/ops-exec-should-not-exist; echo '",
|
|
223 |
+ |
"$(echo pwned)",
|
|
224 |
+ |
"`echo pwned`",
|
|
225 |
+ |
"a\\b\\c",
|
|
226 |
+ |
"line1\nline2",
|
|
227 |
+ |
"$'\\x41'",
|
|
228 |
+ |
"'; rm -rf / #",
|
|
229 |
+ |
"${HOME}",
|
|
230 |
+ |
"* ? [a-z] | & ; ( ) < >",
|
|
231 |
+ |
];
|
|
232 |
+ |
for payload in payloads {
|
|
233 |
+ |
let cmd = format!("printf '%s' {}", sh_quote(payload));
|
|
234 |
+ |
let out = Command::new("sh").arg("-c").arg(&cmd).output().await.unwrap();
|
|
235 |
+ |
assert!(out.status.success(), "sh failed for {payload:?}");
|
|
236 |
+ |
assert_eq!(
|
|
237 |
+ |
String::from_utf8_lossy(&out.stdout),
|
|
238 |
+ |
payload,
|
|
239 |
+ |
"sh_quote did not round-trip {payload:?} verbatim (injection or expansion occurred)"
|
|
240 |
+ |
);
|
|
241 |
+ |
}
|
|
242 |
+ |
// The injection side effect must never have happened.
|
|
243 |
+ |
assert!(!std::path::Path::new("/tmp/ops-exec-should-not-exist").exists());
|
|
244 |
+ |
}
|
|
245 |
+ |
|
| 211 |
246 |
|
#[test]
|
| 212 |
247 |
|
fn local_detection() {
|
| 213 |
248 |
|
assert!(RemoteHost::new("local").is_local());
|