diff --git a/CHANGELOG.md b/CHANGELOG.md index 251168b..098a25a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed ### Fixed +- Fix ReDoS vulnerability in `/nodes/conf_search`: invalid patterns now return HTTP 400 instead + of an unhandled exception, and per-node regex evaluation is wrapped in a 2-second timeout + so a crafted pattern cannot hang a Puma worker thread indefinitely (CWE-1333) ## [0.18.1 – 2026-01-19] diff --git a/lib/oxidized/web/webapp.rb b/lib/oxidized/web/webapp.rb index 5e69e3d..d887639 100644 --- a/lib/oxidized/web/webapp.rb +++ b/lib/oxidized/web/webapp.rb @@ -4,6 +4,7 @@ require 'tilt/haml' require 'htmlentities' require 'charlock_holmes' +require 'timeout' module Oxidized module API require 'oxidized/web/version' @@ -65,13 +66,23 @@ class WebApp < Sinatra::Base end post '/nodes/conf_search.?:format?' do - @to_research = Regexp.new params[:search_in_conf_textbox] + begin + @to_research = Regexp.new(params[:search_in_conf_textbox]) + rescue RegexpError => e + halt 400, "Invalid regular expression: #{e.message}" + end nodes_list = nodes.list.map @nodes_match = [] nodes_list.each do |n| node, @json = route_parse n[:name] config = nodes.fetch node, n[:group] - @nodes_match.push({ node: n[:name], full_name: n[:full_name] }) if config[@to_research] + begin + Timeout.timeout(2) do + @nodes_match.push({ node: n[:name], full_name: n[:full_name] }) if config[@to_research] + end + rescue Timeout::Error + next + end end @data = @nodes_match out :conf_search diff --git a/spec/web/nodes/conf_search_spec.rb b/spec/web/nodes/conf_search_spec.rb new file mode 100644 index 0000000..8db60f9 --- /dev/null +++ b/spec/web/nodes/conf_search_spec.rb @@ -0,0 +1,61 @@ +require_relative '../../spec_helper' +require 'json' + +describe Oxidized::API::WebApp do + include Rack::Test::Methods + + def app + Oxidized::API::WebApp + end + + before do + @nodes = mock('Oxidized::Nodes') + app.set(:nodes, @nodes) + end + + describe 'POST /nodes/conf_search' do + it 'returns 400 for an invalid regular expression' do + post '/nodes/conf_search', search_in_conf_textbox: '[invalid(' + + _(last_response.status).must_equal 400 + _(last_response.body).must_include 'Invalid regular expression' + end + + it 'returns an empty list when no configs match' do + @nodes.expects(:list).returns([{ name: 'sw1', full_name: 'Switch 1' }]) + @nodes.expects(:fetch).with('sw1', nil).returns('hostname sw1') + + post '/nodes/conf_search.json', search_in_conf_textbox: 'interface' + + _(last_response.ok?).must_equal true + _(JSON.parse(last_response.body)).must_equal [] + end + + it 'returns nodes whose configs match the pattern' do + @nodes.expects(:list).returns( + [{ name: 'sw1', full_name: 'Switch 1' }, + { name: 'sw2', full_name: 'Switch 2' }] + ) + @nodes.expects(:fetch).with('sw1', nil).returns("hostname sw1\ninterface GigabitEthernet0/0") + @nodes.expects(:fetch).with('sw2', nil).returns('hostname sw2') + + post '/nodes/conf_search.json', search_in_conf_textbox: 'interface' + + _(last_response.ok?).must_equal true + result = JSON.parse(last_response.body) + _(result.length).must_equal 1 + _(result.first['node']).must_equal 'sw1' + end + + it 'skips nodes that exceed the search timeout and still returns 200' do + @nodes.expects(:list).returns([{ name: 'sw1', full_name: 'Switch 1' }]) + @nodes.expects(:fetch).with('sw1', nil).returns('hostname sw1') + Timeout.stubs(:timeout).raises(Timeout::Error) + + post '/nodes/conf_search.json', search_in_conf_textbox: 'hostname' + + _(last_response.ok?).must_equal true + _(JSON.parse(last_response.body)).must_equal [] + end + end +end