diff --git a/CHANGELOG.md b/CHANGELOG.md index 37cbeb8..c171a09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- CSRF protection: enable Rack::Session::Cookie (SameSite=Strict, HttpOnly) and Rack::Protection::AuthenticityToken on all state-changing endpoints; add authenticity_token to all HTML forms (@mattimustang) +- Security regression tests for CSRF token presence in rendered forms (@mattimustang) - Security regression tests for XSS prevention in node name, version, diff, stats views and HTMLEntities encoding of device config content (@mattimustang, @robertcheramy) ### Changed diff --git a/lib/oxidized/web/views/diffs.haml b/lib/oxidized/web/views/diffs.haml index e19aeb4..2458629 100644 --- a/lib/oxidized/web/views/diffs.haml +++ b/lib/oxidized/web/views/diffs.haml @@ -23,6 +23,7 @@ - params = "node=#{@info[:node]}&group=#{@info[:group]}&oid=#{@info[:oid]}" - params = "#{params}&epoch=#{@info[:time].to_i}&num=#{@info[:num]}" %form{action: url_for("/node/version/diffs?#{params}"), method: 'post', role: 'form'} + %input{type: 'hidden', name: 'authenticity_token', value: Rack::Protection::AuthenticityToken.token(session)} .form-group %select.form-select#oid2{name: 'oid2'} - diff2 = {} diff --git a/lib/oxidized/web/views/layout.haml b/lib/oxidized/web/views/layout.haml index 4c364b8..750288a 100644 --- a/lib/oxidized/web/views/layout.haml +++ b/lib/oxidized/web/views/layout.haml @@ -23,6 +23,7 @@ %form.d-flex{role: 'search', action: url_for('/nodes/conf_search'), method: 'post'} + %input{type: 'hidden', name: 'authenticity_token', value: Rack::Protection::AuthenticityToken.token(session)} %input.form-control.me-2{type: 'text', name: 'search_in_conf_textbox', placeholder: 'Search in Configs', diff --git a/lib/oxidized/web/webapp.rb b/lib/oxidized/web/webapp.rb index c3bc1e0..372f411 100644 --- a/lib/oxidized/web/webapp.rb +++ b/lib/oxidized/web/webapp.rb @@ -3,6 +3,9 @@ require 'tilt/haml' require 'htmlentities' require 'charlock_holmes' +require 'rack/session' +require 'rack/protection' +require 'securerandom' module Oxidized module API require 'oxidized/web/version' @@ -15,6 +18,13 @@ class WebApp < Sinatra::Base # from "'" to '"' (haml/haml#1188); older versions still default to "'". set :haml, { escape_html: true, attr_quote: '"' } + use Rack::Session::Cookie, + key: 'rack.session', + secret: SecureRandom.hex(32), + same_site: :strict, + http_only: true + use Rack::Protection::AuthenticityToken unless ENV['APP_ENV'] == 'test' + get '/' do redirect url_for('/nodes') end diff --git a/spec/web/csrf_spec.rb b/spec/web/csrf_spec.rb new file mode 100644 index 0000000..79bb6b1 --- /dev/null +++ b/spec/web/csrf_spec.rb @@ -0,0 +1,47 @@ +require_relative '../spec_helper' + +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 'CSRF protection' do + it 'includes authenticity_token in the conf_search form on every page' do + @nodes.stubs(:list).returns([]) + + get '/nodes' + + _(last_response.ok?).must_equal true + _(last_response.body).must_include("name='authenticity_token'") + end + + it 'includes authenticity_token in the version diffs form' do + versions = [{ oid: 'C006', time: Time.parse('2025-02-05 19:49:00 +0100') }] + diff = { patch: "- old line\n+ new line\n", stat: [1, 1] } + @nodes.stubs(:version).returns(versions) + @nodes.stubs(:get_diff).returns(diff) + + get '/node/version/diffs?node=sw5&group=&oid=C006&epoch=0&num=1' + + _(last_response.ok?).must_equal true + _(last_response.body).must_include("name='authenticity_token'") + end + + it 'sets a SameSite=Strict session cookie' do + @nodes.stubs(:list).returns([]) + + get '/nodes' + + _(last_response.ok?).must_equal true + cookie = last_response.headers['Set-Cookie'].to_s + _(cookie.downcase).must_include('samesite=strict') + end + end +end