From 7020cededa9b50882f9d4cb6809b8af1047f874f Mon Sep 17 00:00:00 2001 From: Matthew Flanagan <188046+mattimustang@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:39:16 +1000 Subject: [PATCH] Add CSRF protection via Rack::Protection::AuthenticityToken Sinatra::Base subclasses do not enable Rack::Protection by default. This commit adds explicit CSRF protection for all state-changing endpoints. - Add Rack::Session::Cookie (SameSite=Strict, HttpOnly) to establish the session needed for CSRF token storage - Add Rack::Protection::AuthenticityToken to validate tokens on all non-safe requests (POST, PUT, DELETE, PATCH); skipped in the test environment so existing rack/test suites are unaffected - Add authenticity_token hidden input to the conf_search form in layout.haml (present on every page) and to the version-diff form in diffs.haml - Add three regression tests: CSRF token present in conf_search form, CSRF token present in diffs form, session cookie carries SameSite=Strict Fixes CWE-352 / CVSSv4.0 6.3 (CSRF on state-changing endpoints). --- CHANGELOG.md | 2 ++ lib/oxidized/web/views/diffs.haml | 1 + lib/oxidized/web/views/layout.haml | 1 + lib/oxidized/web/webapp.rb | 10 +++++++ spec/web/csrf_spec.rb | 47 ++++++++++++++++++++++++++++++ 5 files changed, 61 insertions(+) create mode 100644 spec/web/csrf_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 251168b..316f158 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) ### 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 5a8ee65..4fde8f1 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 5e69e3d..1aab38c 100644 --- a/lib/oxidized/web/webapp.rb +++ b/lib/oxidized/web/webapp.rb @@ -4,6 +4,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' @@ -13,6 +16,13 @@ class WebApp < Sinatra::Base set :public_folder, proc { File.join(root, 'public') } set :haml, { escape_html: false } + 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