Skip to content

Commit ede1d18

Browse files
Harden exec/encrypt editor invocation, fix Crystal 1.20 deprecations (#22)
* Harden editor invocation against shell injection; fix Crystal 1.20 deprecations exec.cr and encrypt.cr previously called system("#{editor} #{filename}"), allowing shell metacharacters in the EDITOR env var or filename to execute arbitrary commands. Replace with Process.parse_arguments + Process.run so the editor string is word-split (supporting "code -w", "vim -u none", etc.) and the filename is passed as a literal argument with no shell involvement. Add regression specs asserting shell metacharacters in editor/filename do not execute. Fix pattern contributed by @tcoatswo in amberframework/amber#1376. process_runner.cr used Time.monotonic which is deprecated in Crystal 1.20.0 in favour of Time.instant (Time::Instant, introduced in 1.20.0). Replace both call sites. The shard.yml crystal floor is raised to >= 1.20.0 to match (see version bump commit). Fix contributed by @renich in amberframework/amber#1379. amber.js Channel.join/leave/push previously called this.socket.ws.send() without error handling; a dead WebSocket throws an uncaught DOMException in the browser. Wrap each send in try/catch with a fallback to _reconnect(). Fix pattern contributed by @jonsilverman50-star in amberframework/amber#1375. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bump version to 2.0.1; raise Crystal floor to >= 1.20.0 Time.instant (used in process_runner.cr) was introduced in Crystal 1.20.0. Raise the shard.yml crystal constraint from >= 1.10.0 to >= 1.20.0 to reflect this requirement. Bump shard version 2.0.0 -> 2.0.1 to signal the patch release containing the exec/encrypt hardening and deprecation fixes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b5aee82 commit ede1d18

8 files changed

Lines changed: 102 additions & 10 deletions

File tree

shard.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
name: amber_cli
2-
version: 2.0.0
2+
version: 2.0.1
33

44
authors:
55
- crimson-knight <crimsonknightstudios@gmail.com>
66

7-
crystal: ">= 1.10.0, < 2.0"
7+
crystal: ">= 1.20.0, < 2.0"
88

99
license: MIT
1010

spec/amber_cli_spec.cr

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ require "./core/generator_config_spec"
5858
require "./core/template_engine_spec"
5959
require "./commands/base_command_spec"
6060
require "./commands/new_command_spec"
61+
require "./commands/exec_command_spec"
6162
require "./native/capability_manifest_spec"
6263
require "./generators/native_app_spec"
6364
require "./integration/generator_manager_spec"

spec/commands/exec_command_spec.cr

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
require "../amber_cli_spec"
2+
require "../../src/amber_cli/core/base_command"
3+
4+
# Regression spec for shell injection fix in exec/encrypt editor invocation.
5+
# Verifies that shell metacharacters in the editor option or filename are NOT
6+
# executed by the shell. Reference: amberframework/amber#1376 by @tcoatswo.
7+
#
8+
# The vulnerability was: system("#{editor} #{filename}") — a crafted editor
9+
# string like 'vim; touch /tmp/pwned' would execute the injected command.
10+
# The fix uses Process.run with an explicit args array (no shell: true).
11+
describe "ExecCommand editor invocation safety" do
12+
it "does not execute injected shell commands when editor contains semicolon" do
13+
# With system("#{editor} #{filename}"), setting editor = "vim; touch /tmp/pwned"
14+
# would run `touch /tmp/pwned`. With Process.parse_arguments + Process.run,
15+
# the semicolon is part of the editor token, not a shell command separator.
16+
marker = "/tmp/amber_exec_semi_pwned_#{Time.utc.to_unix_ms}"
17+
File.delete(marker) if File.exists?(marker)
18+
19+
# An attacker sets EDITOR=vim; touch <marker>
20+
# Process.parse_arguments on POSIX treats "vim; touch /tmp/..." as a single
21+
# token because there are no shell word-break chars (it's unquoted but
22+
# parse_arguments_posix only splits on whitespace and quotes).
23+
# The resulting command "vim; touch ..." will fail to execute (no such file).
24+
malicious_editor = "vim; touch #{marker}"
25+
editor_parts = Process.parse_arguments(malicious_editor)
26+
editor_cmd = editor_parts.first # => "vim;"
27+
editor_args = editor_parts[1..] + ["/dev/null"]
28+
29+
# Run — we expect an exception (no binary named "vim;") but no marker creation.
30+
begin
31+
Process.run(editor_cmd, editor_args, input: Process::Redirect::Close, output: Process::Redirect::Close, error: Process::Redirect::Close)
32+
rescue File::NotFoundError | File::AccessDeniedError | Exception
33+
# Expected: binary "vim;" doesn't exist, Process.run raises.
34+
end
35+
36+
File.exists?(marker).should be_false
37+
end
38+
39+
it "does not execute injected shell commands when filename contains shell metacharacters" do
40+
# With system("vim #{filename}"), a filename containing "; touch /tmp/pwned"
41+
# would inject a command. With Process.run and explicit args, the filename
42+
# is passed as a single argument — no shell parsing occurs.
43+
marker = "/tmp/amber_exec_fn_pwned_#{Time.utc.to_unix_ms}"
44+
File.delete(marker) if File.exists?(marker)
45+
46+
injected_filename = "/dev/null; touch #{marker}"
47+
48+
# Process.run does NOT invoke the shell, so the semicolon is literal.
49+
# `cat` will fail (no such file "/dev/null; touch /tmp/...") but will
50+
# NOT execute the touch command.
51+
Process.run("cat", [injected_filename], input: Process::Redirect::Close, output: Process::Redirect::Close, error: Process::Redirect::Close)
52+
53+
File.exists?(marker).should be_false
54+
end
55+
56+
it "handles multi-word editor like 'code -w' by splitting on whitespace" do
57+
# When EDITOR="code -w", Process.parse_arguments yields ["code", "-w"].
58+
# The first token is the command and the rest are prepended to the filename.
59+
editor = "code -w"
60+
parts = Process.parse_arguments(editor)
61+
parts.should eq(["code", "-w"])
62+
parts.first.should eq("code")
63+
parts[1..].should eq(["-w"])
64+
end
65+
66+
it "handles multi-word editor like 'vim -u none' by splitting on whitespace" do
67+
editor = "vim -u none"
68+
parts = Process.parse_arguments(editor)
69+
parts.should eq(["vim", "-u", "none"])
70+
parts.first.should eq("vim")
71+
parts[1..].should eq(["-u", "none"])
72+
end
73+
end

src/amber_cli.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ end
3838
Log.builder.bind "*", :info, backend
3939

4040
module AmberCLI
41-
VERSION = "2.0.0"
41+
VERSION = "2.0.1"
4242

4343
def self.run(args = ARGV)
4444
if args.empty?

src/amber_cli/commands/encrypt.cr

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,10 @@ module AmberCLI::Commands
8888

8989
unless no_edit
9090
info "Opening #{unencrypted_file} in #{editor}..."
91-
system("#{editor} #{unencrypted_file}")
91+
editor_parts = Process.parse_arguments(editor)
92+
editor_cmd = editor_parts.first
93+
editor_args = editor_parts[1..] + [unencrypted_file]
94+
Process.run(editor_cmd, editor_args, input: Process::Redirect::Inherit, output: Process::Redirect::Inherit, error: Process::Redirect::Inherit)
9295
end
9396
end
9497

src/amber_cli/commands/exec.cr

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,10 @@ module AmberCLI::Commands
7878

7979
if code.empty? || File.exists?(code)
8080
prepare_file
81-
system("#{editor} #{filename}")
81+
editor_parts = Process.parse_arguments(editor)
82+
editor_cmd = editor_parts.first
83+
editor_args = editor_parts[1..] + [filename]
84+
Process.run(editor_cmd, editor_args, input: Process::Redirect::Inherit, output: Process::Redirect::Inherit, error: Process::Redirect::Inherit)
8285
else
8386
File.write(filename, wrap(code))
8487
end

src/amber_cli/helpers/process_runner.cr

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,11 @@ module Sentry
121121
ok_to_run = true
122122
else
123123
log :run, "Building..."
124-
time = Time.monotonic
124+
time = Time.instant
125125
build_result = Amber::CLI::Helpers.run(build_command_run)
126126
exit 1 unless build_result.is_a? Process::Status
127127
if build_result.success?
128-
log :run, "Compiled in #{(Time.monotonic - time)}"
128+
log :run, "Compiled in #{(Time.instant - time)}"
129129
stop_processes("run") if @app_running
130130
ok_to_run = true
131131
elsif !@app_running # first run

src/amber_cli/templates/app/public/js/amber.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,22 @@ export class Channel {
3939
* Join a channel, subscribe to all channels messages
4040
*/
4141
join() {
42-
this.socket.ws.send(JSON.stringify({ event: EVENTS.join, topic: this.topic }))
42+
try {
43+
this.socket.ws.send(JSON.stringify({ event: EVENTS.join, topic: this.topic }))
44+
} catch {
45+
this._reconnect()
46+
}
4347
}
4448

4549
/**
4650
* Leave a channel, stop subscribing to channel messages
4751
*/
4852
leave() {
49-
this.socket.ws.send(JSON.stringify({ event: EVENTS.leave, topic: this.topic }))
53+
try {
54+
this.socket.ws.send(JSON.stringify({ event: EVENTS.leave, topic: this.topic }))
55+
} catch {
56+
this._reconnect()
57+
}
5058
}
5159

5260
/**
@@ -73,7 +81,11 @@ export class Channel {
7381
* @param {Object} payload - payload object: `{message: 'hello'}`
7482
*/
7583
push(subject, payload) {
76-
this.socket.ws.send(JSON.stringify({ event: EVENTS.message, topic: this.topic, subject: subject, payload: payload }))
84+
try {
85+
this.socket.ws.send(JSON.stringify({ event: EVENTS.message, topic: this.topic, subject: subject, payload: payload }))
86+
} catch {
87+
this._reconnect()
88+
}
7789
}
7890
}
7991

0 commit comments

Comments
 (0)