Skip to content

Commit 5ab5492

Browse files
committed
Harden RDoc::Server: security, concurrency, and robustness fixes
Address review findings from the server-mode implementation: Security: - Prevent path traversal in serve_asset via expand_path containment check - Bind to 127.0.0.1 instead of 0.0.0.0 (localhost-only) Concurrency: - Wrap entire reparse_and_refresh in mutex so store mutations are atomic - Protect @last_change_time read in route with mutex Correctness: - Remove old file data before re-parsing to prevent stale methods/constants - Wrap individual parse_file calls in rescue so one failure doesn't skip store.complete for remaining files - Recurse into nested classes/modules in Store#remove_file - Clean up C variable maps on file removal Robustness: - Add 5-second IO.select timeout on client socket reads - Return 405 Method Not Allowed for non-GET requests - Return 400 Bad Request for missing or malformed URIs - Track watcher thread with @running flag; join on shutdown Nits: - Fix docstring referencing WEBrick in rdoc.rb - Fix doc comments in ri/servlet.rb to reference RDoc::RI::Servlet - Use begin/rescue/end inside while for Ruby 3.0 compatibility
1 parent 7b4efde commit 5ab5492

File tree

4 files changed

+85
-46
lines changed

4 files changed

+85
-46
lines changed

lib/rdoc/rdoc.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ def document(options)
459459
if @options.server_port
460460
@store.load_cache
461461

462-
file_info = parse_files @options.files
462+
parse_files @options.files
463463

464464
@options.default_title = "RDoc Documentation"
465465

@@ -530,7 +530,7 @@ def generate
530530
end
531531

532532
##
533-
# Starts a live-reloading WEBrick server for previewing documentation.
533+
# Starts a live-reloading HTTP server for previewing documentation.
534534
# Called from #document when <tt>--server</tt> is given.
535535

536536
def start_server

lib/rdoc/ri/servlet.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@
2424
#
2525
# server = WEBrick::HTTPServer.new Port: 8000
2626
#
27-
# server.mount '/', RDoc::Servlet
27+
# server.mount '/', RDoc::RI::Servlet
2828
#
2929
# If you want to mount the servlet some other place than the root, provide the
3030
# base path when mounting:
3131
#
32-
# server.mount '/rdoc', RDoc::Servlet, '/rdoc'
32+
# server.mount '/rdoc', RDoc::RI::Servlet, '/rdoc'
3333

3434
class RDoc::RI::Servlet < WEBrick::HTTPServlet::AbstractServlet
3535

lib/rdoc/server.rb

Lines changed: 65 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ class RDoc::Server
3838
'.json' => 'application/json',
3939
}.freeze
4040

41+
STATUS_TEXTS = {
42+
200 => 'OK',
43+
400 => 'Bad Request',
44+
404 => 'Not Found',
45+
405 => 'Method Not Allowed',
46+
500 => 'Internal Server Error',
47+
}.freeze
48+
4149
##
4250
# Creates a new server.
4351
#
@@ -56,15 +64,17 @@ def initialize(rdoc, port)
5664
@search_index_cache = nil
5765
@last_change_time = Time.now.to_f
5866
@mutex = Mutex.new
67+
@running = false
5968
end
6069

6170
##
6271
# Starts the server. Blocks until interrupted.
6372

6473
def start
65-
@tcp_server = TCPServer.new('0.0.0.0', @port)
74+
@tcp_server = TCPServer.new('127.0.0.1', @port)
75+
@running = true
6676

67-
start_watcher(@rdoc.last_modified.keys)
77+
@watcher_thread = start_watcher(@rdoc.last_modified.keys)
6878

6979
url = "http://localhost:#{@port}"
7080
$stderr.puts "\nServing documentation at: \e]8;;#{url}\e\\#{url}\e]8;;\e\\"
@@ -82,7 +92,9 @@ def start
8292
# Shuts down the server.
8393

8494
def shutdown
95+
@running = false
8596
@tcp_server&.close
97+
@watcher_thread&.join(2)
8698
end
8799

88100
private
@@ -101,18 +113,27 @@ def create_generator
101113
def handle_client(client)
102114
client.binmode
103115

116+
return unless IO.select([client], nil, nil, 5)
117+
104118
request_line = client.gets("\n")
105119
return unless request_line
106120

107121
method, request_uri, = request_line.split(' ', 3)
108-
path = URI.parse(request_uri).path
122+
return write_response(client, 400, 'text/plain', 'Bad Request') unless request_uri
123+
124+
begin
125+
path = URI.parse(request_uri).path
126+
rescue URI::InvalidURIError
127+
return write_response(client, 400, 'text/plain', 'Bad Request')
128+
end
109129

110-
# Consume remaining headers (we don't need them)
111130
while (line = client.gets("\n"))
112131
break if line.strip.empty?
113132
end
114133

115-
return unless method == 'GET'
134+
unless method == 'GET'
135+
return write_response(client, 405, 'text/plain', 'Method Not Allowed')
136+
end
116137

117138
status, content_type, body = route(path)
118139
write_response(client, status, content_type, body)
@@ -134,8 +155,10 @@ def handle_client(client)
134155
def route(path)
135156
case path
136157
when '/__status'
137-
[200, 'application/json', JSON.generate(last_change: @last_change_time)]
158+
t = @mutex.synchronize { @last_change_time }
159+
[200, 'application/json', JSON.generate(last_change: t)]
138160
when '/js/search_data.js'
161+
# Search data is dynamically generated, not a static asset
139162
serve_page(path)
140163
when %r{\A/(?:css|js)/}
141164
serve_asset(path)
@@ -148,10 +171,9 @@ def route(path)
148171
# Writes an HTTP/1.1 response to +client+.
149172

150173
def write_response(client, status, content_type, body)
151-
status_text = { 200 => 'OK', 404 => 'Not Found', 500 => 'Internal Server Error' }
152174
body_bytes = body.b
153175

154-
header = +"HTTP/1.1 #{status} #{status_text[status] || 'Unknown'}\r\n"
176+
header = +"HTTP/1.1 #{status} #{STATUS_TEXTS[status] || 'Unknown'}\r\n"
155177
header << "Content-Type: #{content_type}\r\n"
156178
header << "Content-Length: #{body_bytes.bytesize}\r\n"
157179
header << "Connection: close\r\n"
@@ -168,14 +190,16 @@ def write_response(client, status, content_type, body)
168190
def serve_asset(path)
169191
rel_path = path.sub(%r{\A/}, '')
170192
asset_path = File.join(@generator.template_dir, rel_path)
193+
real_asset = File.expand_path(asset_path)
194+
real_template = File.expand_path(@generator.template_dir)
171195

172-
unless File.file?(asset_path)
196+
unless real_asset.start_with?("#{real_template}/") && File.file?(real_asset)
173197
return [404, 'text/plain', "Asset not found: #{rel_path}"]
174198
end
175199

176200
ext = File.extname(rel_path)
177201
content_type = CONTENT_TYPES[ext] || 'application/octet-stream'
178-
[200, content_type, File.read(asset_path)]
202+
[200, content_type, File.read(real_asset)]
179203
end
180204

181205
##
@@ -242,10 +266,8 @@ def generate_page(name)
242266
# Builds the search index JavaScript.
243267

244268
def build_search_index
245-
@search_index_cache ||= begin
246-
index = @generator.build_search_index
247-
"var search_data = #{JSON.generate(index: index)};"
248-
end
269+
@search_index_cache ||=
270+
"var search_data = #{JSON.generate(index: @generator.build_search_index)};"
249271
end
250272

251273
##
@@ -273,11 +295,13 @@ def start_watcher(source_files)
273295
end
274296

275297
Thread.new do
276-
loop do
277-
sleep 1
278-
check_for_changes
279-
rescue => e
280-
$stderr.puts "RDoc server watcher error: #{e.message}"
298+
while @running
299+
begin
300+
sleep 1
301+
check_for_changes
302+
rescue => e
303+
$stderr.puts "RDoc server watcher error: #{e.message}"
304+
end
281305
end
282306
end
283307
end
@@ -290,7 +314,6 @@ def check_for_changes
290314
changed = []
291315
removed = []
292316

293-
# Check existing tracked files for modifications or deletions
294317
@file_mtimes.each do |file, old_mtime|
295318
unless File.exist?(file)
296319
removed << file
@@ -302,7 +325,6 @@ def check_for_changes
302325
changed << file if old_mtime.nil? || current_mtime > old_mtime
303326
end
304327

305-
# Scan for new files using the same directory walking logic
306328
file_list = @rdoc.normalized_file_list(
307329
@options.files.empty? ? [@options.root.to_s] : @options.files,
308330
true, @options.exclude
@@ -346,24 +368,32 @@ def relative_path_for(filename)
346368
# refreshes the generator, and invalidates caches.
347369

348370
def reparse_and_refresh(changed_files, removed_files)
349-
unless removed_files.empty?
350-
$stderr.puts "Removed: #{removed_files.join(', ')}"
351-
removed_files.each do |f|
352-
@file_mtimes.delete(f)
353-
relative = relative_path_for(f)
354-
@store.remove_file(relative)
371+
@mutex.synchronize do
372+
unless removed_files.empty?
373+
$stderr.puts "Removed: #{removed_files.join(', ')}"
374+
removed_files.each do |f|
375+
@file_mtimes.delete(f)
376+
relative = relative_path_for(f)
377+
@store.remove_file(relative)
378+
end
355379
end
356-
end
357380

358-
unless changed_files.empty?
359-
$stderr.puts "Re-parsing: #{changed_files.join(', ')}"
360-
changed_files.each { |f| @rdoc.parse_file(f) }
361-
changed_files.each { |f| @file_mtimes[f] = File.mtime(f) rescue nil }
362-
end
381+
unless changed_files.empty?
382+
$stderr.puts "Re-parsing: #{changed_files.join(', ')}"
383+
changed_files.each do |f|
384+
begin
385+
relative = relative_path_for(f)
386+
@store.remove_file(relative)
387+
@rdoc.parse_file(f)
388+
@file_mtimes[f] = File.mtime(f) rescue nil
389+
rescue => e
390+
$stderr.puts "Error parsing #{f}: #{e.message}"
391+
end
392+
end
393+
end
363394

364-
@rdoc.store.complete(@options.visibility)
395+
@store.complete(@options.visibility)
365396

366-
@mutex.synchronize do
367397
@generator.refresh_store_data
368398
invalidate_all_caches
369399
@last_change_time = Time.now.to_f

lib/rdoc/store.rb

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -206,15 +206,11 @@ def add_file(absolute_name, relative_name: absolute_name, parser: nil)
206206
def remove_file(relative_name)
207207
top_level = @files_hash.delete(relative_name)
208208
@text_files_hash.delete(relative_name)
209+
@c_class_variables.delete(relative_name)
210+
@c_singleton_class_variables.delete(relative_name)
209211
return unless top_level
210212

211-
top_level.classes_or_modules.each do |cm|
212-
if cm.is_a?(RDoc::NormalModule)
213-
@modules_hash.delete(cm.full_name)
214-
else
215-
@classes_hash.delete(cm.full_name)
216-
end
217-
end
213+
remove_classes_and_modules(top_level.classes_or_modules)
218214
end
219215

220216
##
@@ -1002,6 +998,19 @@ def unique_modules
1002998
end
1003999

10041000
private
1001+
1002+
def remove_classes_and_modules(cms)
1003+
cms.each do |cm|
1004+
remove_classes_and_modules(cm.classes_and_modules)
1005+
1006+
if cm.is_a?(RDoc::NormalModule)
1007+
@modules_hash.delete(cm.full_name)
1008+
else
1009+
@classes_hash.delete(cm.full_name)
1010+
end
1011+
end
1012+
end
1013+
10051014
def marshal_load(file)
10061015
File.open(file, 'rb') {|io| Marshal.load(io, MarshalFilter)}
10071016
end

0 commit comments

Comments
 (0)