wsv 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE.txt +21 -0
- data/README.md +97 -0
- data/bin/wsv +6 -0
- data/lib/wsv/app.rb +38 -0
- data/lib/wsv/cli.rb +88 -0
- data/lib/wsv/mime_types.rb +32 -0
- data/lib/wsv/path_resolver.rb +111 -0
- data/lib/wsv/request.rb +68 -0
- data/lib/wsv/response.rb +67 -0
- data/lib/wsv/server.rb +217 -0
- data/lib/wsv/status.rb +22 -0
- data/lib/wsv/version.rb +5 -0
- data/lib/wsv.rb +14 -0
- data/test/app_test.rb +77 -0
- data/test/cli_test.rb +71 -0
- data/test/path_resolver_test.rb +183 -0
- data/test/request_test.rb +61 -0
- data/test/response_test.rb +48 -0
- data/test/server_test.rb +310 -0
- data/test/test_helper.rb +9 -0
- data/wsv.gemspec +36 -0
- metadata +67 -0
data/lib/wsv/server.rb
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require_relative "app"
|
|
5
|
+
require_relative "request"
|
|
6
|
+
require_relative "response"
|
|
7
|
+
|
|
8
|
+
module Wsv
|
|
9
|
+
class Server
|
|
10
|
+
DEFAULT_READ_TIMEOUT = 10
|
|
11
|
+
DEFAULT_MAX_CONNECTIONS = 8
|
|
12
|
+
DRAIN_TIMEOUT = 5
|
|
13
|
+
|
|
14
|
+
attr_reader :host, :port, :root
|
|
15
|
+
|
|
16
|
+
def initialize(
|
|
17
|
+
host:,
|
|
18
|
+
port:,
|
|
19
|
+
root:,
|
|
20
|
+
out: $stdout,
|
|
21
|
+
err: $stderr,
|
|
22
|
+
read_timeout: DEFAULT_READ_TIMEOUT,
|
|
23
|
+
max_connections: DEFAULT_MAX_CONNECTIONS
|
|
24
|
+
)
|
|
25
|
+
@host = host
|
|
26
|
+
@port = port
|
|
27
|
+
@root = File.realpath(root)
|
|
28
|
+
@out = out
|
|
29
|
+
@err = err
|
|
30
|
+
@read_timeout = read_timeout
|
|
31
|
+
@max_connections = max_connections
|
|
32
|
+
@app = App.new(@root)
|
|
33
|
+
@running = false
|
|
34
|
+
@mutex = Mutex.new
|
|
35
|
+
@active = 0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def start
|
|
39
|
+
@server = TCPServer.new(host, port)
|
|
40
|
+
@running = true
|
|
41
|
+
log_startup
|
|
42
|
+
trap_signals
|
|
43
|
+
accept_loop
|
|
44
|
+
ensure
|
|
45
|
+
close
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def stop
|
|
49
|
+
@running = false
|
|
50
|
+
close
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def handle(client)
|
|
54
|
+
reader = DeadlineReader.new(client, Time.now + @read_timeout)
|
|
55
|
+
request = Request.parse(reader)
|
|
56
|
+
case request
|
|
57
|
+
when :empty
|
|
58
|
+
nil
|
|
59
|
+
when :malformed
|
|
60
|
+
write_response(client, Response.text(400))
|
|
61
|
+
else
|
|
62
|
+
write_response(client, @app.call(request))
|
|
63
|
+
end
|
|
64
|
+
rescue Request::TooLarge => e
|
|
65
|
+
write_response(client, Response.text(e.status_code))
|
|
66
|
+
rescue IO::TimeoutError
|
|
67
|
+
write_response(client, Response.text(408))
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
@err.puts "wsv: #{e.class}: #{e.message}"
|
|
70
|
+
write_response(client, Response.text(400))
|
|
71
|
+
ensure
|
|
72
|
+
graceful_close(client)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def write_response(client, response)
|
|
78
|
+
return if client.closed?
|
|
79
|
+
|
|
80
|
+
response.write_to(client)
|
|
81
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def graceful_close(client)
|
|
86
|
+
return if client.closed?
|
|
87
|
+
|
|
88
|
+
drain_recv(client)
|
|
89
|
+
rescue StandardError
|
|
90
|
+
nil
|
|
91
|
+
ensure
|
|
92
|
+
begin
|
|
93
|
+
client.close unless client.closed?
|
|
94
|
+
rescue StandardError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def drain_recv(client)
|
|
100
|
+
deadline = Time.now + DRAIN_TIMEOUT
|
|
101
|
+
loop do
|
|
102
|
+
return if Time.now >= deadline
|
|
103
|
+
|
|
104
|
+
chunk = client.read_nonblock(8192, exception: false)
|
|
105
|
+
case chunk
|
|
106
|
+
when nil, :wait_writable
|
|
107
|
+
return
|
|
108
|
+
when :wait_readable
|
|
109
|
+
remaining = deadline - Time.now
|
|
110
|
+
return if remaining <= 0
|
|
111
|
+
return unless client.wait_readable([remaining, 0.2].min)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
class DeadlineReader
|
|
117
|
+
def initialize(io, deadline)
|
|
118
|
+
@io = io
|
|
119
|
+
@deadline = deadline
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def gets(limit)
|
|
123
|
+
remaining = @deadline - Time.now
|
|
124
|
+
raise IO::TimeoutError if remaining <= 0
|
|
125
|
+
|
|
126
|
+
@io.timeout = remaining
|
|
127
|
+
@io.gets(limit)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def accept_loop
|
|
132
|
+
while @running
|
|
133
|
+
client = nil
|
|
134
|
+
begin
|
|
135
|
+
client = @server.accept
|
|
136
|
+
rescue IOError, Errno::EBADF
|
|
137
|
+
break
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
@err.puts "wsv: accept error: #{e.class}: #{e.message}"
|
|
140
|
+
sleep 0.05
|
|
141
|
+
next
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
begin
|
|
145
|
+
spawn_handler(client)
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
@err.puts "wsv: dispatch error: #{e.class}: #{e.message}"
|
|
148
|
+
begin
|
|
149
|
+
client.close
|
|
150
|
+
rescue StandardError
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def spawn_handler(client)
|
|
158
|
+
accepted = @mutex.synchronize do
|
|
159
|
+
next false if @active >= @max_connections
|
|
160
|
+
|
|
161
|
+
@active += 1
|
|
162
|
+
true
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
return reject(client) unless accepted
|
|
166
|
+
|
|
167
|
+
Thread.new do
|
|
168
|
+
Thread.current.report_on_exception = false
|
|
169
|
+
handle(client)
|
|
170
|
+
ensure
|
|
171
|
+
@mutex.synchronize { @active -= 1 }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def reject(client)
|
|
176
|
+
write_response(client, Response.text(503))
|
|
177
|
+
ensure
|
|
178
|
+
graceful_close(client)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def close
|
|
182
|
+
@server&.close unless @server&.closed?
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def trap_signals
|
|
186
|
+
%w[INT TERM].each do |signal|
|
|
187
|
+
Signal.trap(signal) do
|
|
188
|
+
@out.puts "\nStopping wsv."
|
|
189
|
+
stop
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
rescue ArgumentError
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def log_startup
|
|
197
|
+
@out.puts "Serving: #{root}"
|
|
198
|
+
@out.puts "Bind: #{url_for(host)}"
|
|
199
|
+
@out.puts "Local: #{url_for('127.0.0.1')}" unless localhost?(host)
|
|
200
|
+
@out.puts "Stop: Ctrl-C"
|
|
201
|
+
warn_public_bind unless localhost?(host)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def warn_public_bind
|
|
205
|
+
@err.puts "WARNING: binding to #{host} exposes #{root} on your network."
|
|
206
|
+
@err.puts " Pass --host 127.0.0.1 (or omit --host) for local-only access."
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def url_for(display_host)
|
|
210
|
+
"http://#{display_host}:#{port}/"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def localhost?(display_host)
|
|
214
|
+
["127.0.0.1", "localhost", "::1"].include?(display_host)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
data/lib/wsv/status.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wsv
|
|
4
|
+
module Status
|
|
5
|
+
REASONS = {
|
|
6
|
+
200 => "OK",
|
|
7
|
+
301 => "Moved Permanently",
|
|
8
|
+
400 => "Bad Request",
|
|
9
|
+
403 => "Forbidden",
|
|
10
|
+
404 => "Not Found",
|
|
11
|
+
405 => "Method Not Allowed",
|
|
12
|
+
408 => "Request Timeout",
|
|
13
|
+
414 => "URI Too Long",
|
|
14
|
+
431 => "Request Header Fields Too Large",
|
|
15
|
+
503 => "Service Unavailable"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def self.reason(code)
|
|
19
|
+
REASONS.fetch(code)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/wsv/version.rb
ADDED
data/lib/wsv.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "wsv/version"
|
|
4
|
+
require_relative "wsv/status"
|
|
5
|
+
require_relative "wsv/mime_types"
|
|
6
|
+
require_relative "wsv/path_resolver"
|
|
7
|
+
require_relative "wsv/request"
|
|
8
|
+
require_relative "wsv/response"
|
|
9
|
+
require_relative "wsv/app"
|
|
10
|
+
require_relative "wsv/server"
|
|
11
|
+
require_relative "wsv/cli"
|
|
12
|
+
|
|
13
|
+
module Wsv
|
|
14
|
+
end
|
data/test/app_test.rb
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class AppTest < Minitest::Test
|
|
6
|
+
def setup
|
|
7
|
+
@dir = Dir.mktmpdir
|
|
8
|
+
@app = Wsv::App.new(File.realpath(@dir))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def teardown
|
|
12
|
+
FileUtils.remove_entry(@dir)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def test_returns_file_response
|
|
16
|
+
File.write(File.join(@dir, "hello.txt"), "hi")
|
|
17
|
+
|
|
18
|
+
response = @app.call(req("GET", "/hello.txt"))
|
|
19
|
+
|
|
20
|
+
assert_equal 200, response.status
|
|
21
|
+
assert_equal "text/plain; charset=utf-8", response.headers["Content-Type"]
|
|
22
|
+
assert_equal "2", response.headers["Content-Length"]
|
|
23
|
+
assert_equal "hi", response.body
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def test_method_not_allowed
|
|
27
|
+
response = @app.call(req("POST", "/"))
|
|
28
|
+
|
|
29
|
+
assert_equal 405, response.status
|
|
30
|
+
assert_equal "GET, HEAD", response.headers["Allow"]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_dotfile_forbidden
|
|
34
|
+
File.write(File.join(@dir, ".env"), "secret")
|
|
35
|
+
|
|
36
|
+
response = @app.call(req("GET", "/.env"))
|
|
37
|
+
|
|
38
|
+
assert_equal 403, response.status
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def test_path_traversal_forbidden
|
|
42
|
+
response = @app.call(req("GET", "/../etc/passwd"))
|
|
43
|
+
|
|
44
|
+
assert_equal 403, response.status
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def test_url_encoded_traversal_forbidden
|
|
48
|
+
response = @app.call(req("GET", "/%2e%2e/passwd"))
|
|
49
|
+
|
|
50
|
+
assert_equal 403, response.status
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_redirect_preserves_query
|
|
54
|
+
FileUtils.mkdir_p(File.join(@dir, "docs"))
|
|
55
|
+
File.write(File.join(@dir, "docs", "index.html"), "x")
|
|
56
|
+
|
|
57
|
+
response = @app.call(req("GET", "/docs?q=1"))
|
|
58
|
+
|
|
59
|
+
assert_equal 301, response.status
|
|
60
|
+
assert_equal "/docs/?q=1", response.headers["Location"]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def test_head_omits_body_but_keeps_content_length
|
|
64
|
+
File.write(File.join(@dir, "x.txt"), "hi")
|
|
65
|
+
|
|
66
|
+
response = @app.call(req("HEAD", "/x.txt"))
|
|
67
|
+
|
|
68
|
+
assert_equal "", response.body
|
|
69
|
+
assert_equal "2", response.headers["Content-Length"]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def req(method, target)
|
|
75
|
+
Wsv::Request.new(method: method, target: target, version: "HTTP/1.1", headers: {})
|
|
76
|
+
end
|
|
77
|
+
end
|
data/test/cli_test.rb
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class CLITest < Minitest::Test
|
|
6
|
+
def test_defaults
|
|
7
|
+
Dir.mktmpdir do |dir|
|
|
8
|
+
Dir.chdir(dir) do
|
|
9
|
+
options = Wsv::CLI.new([]).parse_options([])
|
|
10
|
+
|
|
11
|
+
assert_equal "127.0.0.1", options[:host]
|
|
12
|
+
assert_equal 8000, options[:port]
|
|
13
|
+
assert_equal File.realpath(dir), options[:directory]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_host_port_and_directory
|
|
19
|
+
Dir.mktmpdir do |dir|
|
|
20
|
+
options = Wsv::CLI.new([]).parse_options(["-h", "127.0.0.1", "-p", "3000", dir])
|
|
21
|
+
|
|
22
|
+
assert_equal "127.0.0.1", options[:host]
|
|
23
|
+
assert_equal 3000, options[:port]
|
|
24
|
+
assert_equal dir, options[:directory]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def test_long_host_port_and_directory
|
|
29
|
+
Dir.mktmpdir do |dir|
|
|
30
|
+
options = Wsv::CLI.new([]).parse_options(["--host", "localhost", "--port", "4567", dir])
|
|
31
|
+
|
|
32
|
+
assert_equal "localhost", options[:host]
|
|
33
|
+
assert_equal 4567, options[:port]
|
|
34
|
+
assert_equal dir, options[:directory]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def test_help
|
|
39
|
+
out = StringIO.new
|
|
40
|
+
code = Wsv::CLI.new(["--help"], out: out).run
|
|
41
|
+
|
|
42
|
+
assert_equal 0, code
|
|
43
|
+
assert_includes out.string, "Usage: wsv"
|
|
44
|
+
assert_includes out.string, "--host HOST"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def test_version
|
|
48
|
+
out = StringIO.new
|
|
49
|
+
code = Wsv::CLI.new(["--version"], out: out).run
|
|
50
|
+
|
|
51
|
+
assert_equal 0, code
|
|
52
|
+
assert_equal "#{Wsv::VERSION}\n", out.string
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def test_invalid_port
|
|
56
|
+
err = StringIO.new
|
|
57
|
+
code = Wsv::CLI.new(["-p", "0"], err: err).run
|
|
58
|
+
|
|
59
|
+
assert_equal 1, code
|
|
60
|
+
assert_includes err.string, "port must be between 1 and 65535"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def test_missing_directory
|
|
64
|
+
err = StringIO.new
|
|
65
|
+
missing = File.join(Dir.tmpdir, "wsv-missing-#{Time.now.to_i}-#{$$}")
|
|
66
|
+
code = Wsv::CLI.new([missing], err: err).run
|
|
67
|
+
|
|
68
|
+
assert_equal 1, code
|
|
69
|
+
assert_includes err.string, "directory does not exist"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class PathResolverTest < Minitest::Test
|
|
6
|
+
def setup
|
|
7
|
+
@dir = Dir.mktmpdir
|
|
8
|
+
@root = File.realpath(@dir)
|
|
9
|
+
@resolver = Wsv::PathResolver.new(@root)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def teardown
|
|
13
|
+
FileUtils.remove_entry(@dir)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def test_resolves_existing_file
|
|
17
|
+
path = File.join(@dir, "hello.txt")
|
|
18
|
+
File.write(path, "hi")
|
|
19
|
+
|
|
20
|
+
result = @resolver.resolve("/hello.txt")
|
|
21
|
+
|
|
22
|
+
assert_predicate result, :file?
|
|
23
|
+
assert_equal File.realpath(path), result.file
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def test_returns_404_for_missing_file
|
|
27
|
+
result = @resolver.resolve("/nope.txt")
|
|
28
|
+
|
|
29
|
+
assert_predicate result, :error?
|
|
30
|
+
assert_equal 404, result.status
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_redirects_directory_without_trailing_slash
|
|
34
|
+
FileUtils.mkdir_p(File.join(@dir, "docs"))
|
|
35
|
+
File.write(File.join(@dir, "docs", "index.html"), "x")
|
|
36
|
+
|
|
37
|
+
result = @resolver.resolve("/docs")
|
|
38
|
+
|
|
39
|
+
assert_predicate result, :redirect?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def test_serves_index_for_directory_with_trailing_slash
|
|
43
|
+
FileUtils.mkdir_p(File.join(@dir, "docs"))
|
|
44
|
+
index = File.join(@dir, "docs", "index.html")
|
|
45
|
+
File.write(index, "x")
|
|
46
|
+
|
|
47
|
+
result = @resolver.resolve("/docs/")
|
|
48
|
+
|
|
49
|
+
assert_predicate result, :file?
|
|
50
|
+
assert_equal File.realpath(index), result.file
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_directory_without_index_is_404
|
|
54
|
+
FileUtils.mkdir_p(File.join(@dir, "assets"))
|
|
55
|
+
|
|
56
|
+
result = @resolver.resolve("/assets/")
|
|
57
|
+
|
|
58
|
+
assert_predicate result, :error?
|
|
59
|
+
assert_equal 404, result.status
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def test_rejects_path_traversal
|
|
63
|
+
result = @resolver.resolve("/../etc/passwd")
|
|
64
|
+
|
|
65
|
+
assert_predicate result, :error?
|
|
66
|
+
assert_equal 403, result.status
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def test_rejects_dotfile_at_root
|
|
70
|
+
File.write(File.join(@dir, ".env"), "secret")
|
|
71
|
+
|
|
72
|
+
result = @resolver.resolve("/.env")
|
|
73
|
+
|
|
74
|
+
assert_predicate result, :error?
|
|
75
|
+
assert_equal 403, result.status
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def test_rejects_dot_directory
|
|
79
|
+
FileUtils.mkdir_p(File.join(@dir, ".git"))
|
|
80
|
+
File.write(File.join(@dir, ".git", "config"), "x")
|
|
81
|
+
|
|
82
|
+
result = @resolver.resolve("/.git/config")
|
|
83
|
+
|
|
84
|
+
assert_predicate result, :error?
|
|
85
|
+
assert_equal 403, result.status
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def test_rejects_dotfile_in_subdir
|
|
89
|
+
FileUtils.mkdir_p(File.join(@dir, "sub"))
|
|
90
|
+
File.write(File.join(@dir, "sub", ".secret"), "x")
|
|
91
|
+
|
|
92
|
+
result = @resolver.resolve("/sub/.secret")
|
|
93
|
+
|
|
94
|
+
assert_predicate result, :error?
|
|
95
|
+
assert_equal 403, result.status
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def test_rejects_url_encoded_traversal
|
|
99
|
+
result = @resolver.resolve("/%2e%2e/etc/passwd")
|
|
100
|
+
|
|
101
|
+
assert_predicate result, :error?
|
|
102
|
+
assert_equal 403, result.status
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def test_rejects_url_encoded_dotfile
|
|
106
|
+
File.write(File.join(@dir, ".env"), "secret")
|
|
107
|
+
|
|
108
|
+
result = @resolver.resolve("/%2eenv")
|
|
109
|
+
|
|
110
|
+
assert_predicate result, :error?
|
|
111
|
+
assert_equal 403, result.status
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def test_preserves_literal_plus_in_path
|
|
115
|
+
File.write(File.join(@dir, "foo+bar.txt"), "x")
|
|
116
|
+
|
|
117
|
+
result = @resolver.resolve("/foo+bar.txt")
|
|
118
|
+
|
|
119
|
+
assert_predicate result, :file?
|
|
120
|
+
assert_equal File.realpath(File.join(@dir, "foo+bar.txt")), result.file
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def test_returns_400_for_invalid_uri
|
|
124
|
+
result = @resolver.resolve("http://[invalid")
|
|
125
|
+
|
|
126
|
+
assert_predicate result, :error?
|
|
127
|
+
assert_equal 400, result.status
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def test_rejects_symlink_to_dotfile
|
|
131
|
+
File.write(File.join(@dir, ".env"), "secret")
|
|
132
|
+
File.symlink(".env", File.join(@dir, "config"))
|
|
133
|
+
|
|
134
|
+
result = @resolver.resolve("/config")
|
|
135
|
+
|
|
136
|
+
assert_predicate result, :error?
|
|
137
|
+
assert_equal 403, result.status
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def test_rejects_symlink_to_dot_directory
|
|
141
|
+
FileUtils.mkdir_p(File.join(@dir, ".git"))
|
|
142
|
+
File.write(File.join(@dir, ".git", "HEAD"), "ref")
|
|
143
|
+
File.symlink(".git", File.join(@dir, "gitstuff"))
|
|
144
|
+
|
|
145
|
+
result = @resolver.resolve("/gitstuff/HEAD")
|
|
146
|
+
|
|
147
|
+
assert_predicate result, :error?
|
|
148
|
+
assert_equal 403, result.status
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def test_allows_internal_symlink_to_regular_file
|
|
152
|
+
File.write(File.join(@dir, "real.txt"), "data")
|
|
153
|
+
File.symlink("real.txt", File.join(@dir, "alias.txt"))
|
|
154
|
+
|
|
155
|
+
result = @resolver.resolve("/alias.txt")
|
|
156
|
+
|
|
157
|
+
assert_predicate result, :file?
|
|
158
|
+
assert_equal File.realpath(File.join(@dir, "real.txt")), result.file
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def test_handles_symlink_loop
|
|
162
|
+
File.symlink("b", File.join(@dir, "a"))
|
|
163
|
+
File.symlink("a", File.join(@dir, "b"))
|
|
164
|
+
|
|
165
|
+
result = @resolver.resolve("/a")
|
|
166
|
+
|
|
167
|
+
assert_predicate result, :error?
|
|
168
|
+
assert_equal 404, result.status
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def test_rejects_symlink_outside_root
|
|
172
|
+
outside = File.join(File.dirname(@dir), "wsv-outside-#{$$}")
|
|
173
|
+
File.write(outside, "leaked")
|
|
174
|
+
File.symlink(outside, File.join(@dir, "link"))
|
|
175
|
+
|
|
176
|
+
result = @resolver.resolve("/link")
|
|
177
|
+
|
|
178
|
+
assert_predicate result, :error?
|
|
179
|
+
assert_equal 403, result.status
|
|
180
|
+
ensure
|
|
181
|
+
FileUtils.rm_f(outside) if outside
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class RequestTest < Minitest::Test
|
|
6
|
+
def test_parse_simple_get
|
|
7
|
+
io = StringIO.new("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")
|
|
8
|
+
|
|
9
|
+
request = Wsv::Request.parse(io)
|
|
10
|
+
|
|
11
|
+
assert_equal "GET", request.method
|
|
12
|
+
assert_equal "/", request.target
|
|
13
|
+
assert_equal "HTTP/1.1", request.version
|
|
14
|
+
assert_equal "localhost", request.headers["host"]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def test_parse_empty_returns_symbol
|
|
18
|
+
assert_equal :empty, Wsv::Request.parse(StringIO.new(""))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def test_parse_malformed_returns_symbol
|
|
22
|
+
assert_equal :malformed, Wsv::Request.parse(StringIO.new("garbage line\r\n"))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_request_line_too_long_raises_414
|
|
26
|
+
long_path = "/#{'a' * 9000}"
|
|
27
|
+
io = StringIO.new("GET #{long_path} HTTP/1.1\r\nHost: x\r\n\r\n")
|
|
28
|
+
|
|
29
|
+
error = assert_raises(Wsv::Request::TooLarge) { Wsv::Request.parse(io) }
|
|
30
|
+
|
|
31
|
+
assert_equal 414, error.status_code
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def test_too_many_headers_raises_431
|
|
35
|
+
headers = (1..200).map { |i| "X-Custom-#{i}: x\r\n" }.join
|
|
36
|
+
io = StringIO.new("GET / HTTP/1.1\r\n#{headers}\r\n")
|
|
37
|
+
|
|
38
|
+
error = assert_raises(Wsv::Request::TooLarge) { Wsv::Request.parse(io) }
|
|
39
|
+
|
|
40
|
+
assert_equal 431, error.status_code
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def test_header_total_too_large_raises_431
|
|
44
|
+
big_value = "a" * 5000
|
|
45
|
+
headers = (1..5).map { |i| "X-Big-#{i}: #{big_value}\r\n" }.join
|
|
46
|
+
io = StringIO.new("GET / HTTP/1.1\r\n#{headers}\r\n")
|
|
47
|
+
|
|
48
|
+
error = assert_raises(Wsv::Request::TooLarge) { Wsv::Request.parse(io) }
|
|
49
|
+
|
|
50
|
+
assert_equal 431, error.status_code
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_single_header_line_too_long_raises_431
|
|
54
|
+
big_header = "X-Long: #{'z' * 9000}\r\n"
|
|
55
|
+
io = StringIO.new("GET / HTTP/1.1\r\n#{big_header}\r\n")
|
|
56
|
+
|
|
57
|
+
error = assert_raises(Wsv::Request::TooLarge) { Wsv::Request.parse(io) }
|
|
58
|
+
|
|
59
|
+
assert_equal 431, error.status_code
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class ResponseTest < Minitest::Test
|
|
6
|
+
def test_rejects_crlf_in_header_value
|
|
7
|
+
assert_raises(ArgumentError) do
|
|
8
|
+
Wsv::Response.new(status: 200, headers: { "X-Foo" => "bad\r\nInjected: yes" })
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def test_rejects_lf_in_header_value
|
|
13
|
+
assert_raises(ArgumentError) do
|
|
14
|
+
Wsv::Response.new(status: 200, headers: { "X-Foo" => "bad\nthing" })
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_rejects_crlf_in_header_name
|
|
19
|
+
assert_raises(ArgumentError) do
|
|
20
|
+
Wsv::Response.new(status: 200, headers: { "X-Bad\r\n" => "value" })
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def test_rejects_colon_in_header_name
|
|
25
|
+
assert_raises(ArgumentError) do
|
|
26
|
+
Wsv::Response.new(status: 200, headers: { "X-Foo: extra" => "value" })
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_accepts_normal_headers
|
|
31
|
+
Wsv::Response.new(
|
|
32
|
+
status: 200,
|
|
33
|
+
headers: {
|
|
34
|
+
"Content-Type" => "text/html; charset=utf-8",
|
|
35
|
+
"Allow" => "GET, HEAD",
|
|
36
|
+
"Location" => "/docs/?q=1"
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def test_text_factory_produces_writable_response
|
|
42
|
+
response = Wsv::Response.text(404)
|
|
43
|
+
io = StringIO.new
|
|
44
|
+
response.write_to(io)
|
|
45
|
+
|
|
46
|
+
assert_includes io.string, "HTTP/1.1 404 Not Found"
|
|
47
|
+
end
|
|
48
|
+
end
|