Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
15 changes: 13 additions & 2 deletions lib/oxidized/web/webapp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require 'tilt/haml'
require 'htmlentities'
require 'charlock_holmes'
require 'timeout'
module Oxidized
module API
require 'oxidized/web/version'
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions spec/web/nodes/conf_search_spec.rb
Original file line number Diff line number Diff line change
@@ -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