diff --git a/.rubocop.yml b/.rubocop.yml index 1691893..88ef3ae 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -20,10 +20,10 @@ Security/YAMLLoad: Exclude: - spec/**/* -# Style/HashSyntax: -# Enabled: true -# Exclude: -# - Rakefile +Style/HashSyntax: + Enabled: true + Exclude: + - Rakefile Style/SymbolArray: Enabled: true diff --git a/Gemfile b/Gemfile index 56fb0e5..99fd241 100644 --- a/Gemfile +++ b/Gemfile @@ -4,7 +4,7 @@ source 'https://rubygems.org' ruby File.read('.ruby-version').strip # Web -gem 'puma', '~> 5' +gem 'puma', '~> 5.3.1' gem 'roda' gem 'slim' diff --git a/Gemfile.lock b/Gemfile.lock index 58dfa27..c7652ed 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -75,7 +75,7 @@ GEM coderay (~> 1.1) method_source (~> 1.0) public_suffix (4.0.7) - puma (5.6.4) + puma (5.3.2) nio4r (~> 2.0) rack (2.2.3) rack-ssl-enforcer (0.2.9) @@ -115,6 +115,7 @@ GEM rubocop (>= 1.7.0, < 2.0) rubocop-ast (>= 0.4.0) ruby-progressbar (1.11.0) + secure_headers (6.3.3) slim (4.1.0) temple (>= 0.7.6, < 0.9) tilt (>= 2.0.6, < 2.1) @@ -141,7 +142,7 @@ DEPENDENCIES minitest minitest-rg pry - puma (~> 5) + puma (~> 5.3.1) rack-ssl-enforcer rack-test rake @@ -152,6 +153,7 @@ DEPENDENCIES roda rubocop rubocop-performance + secure_headers slim webmock diff --git a/Rakefile b/Rakefile index 04cf77b..4e57625 100644 --- a/Rakefile +++ b/Rakefile @@ -1,11 +1,10 @@ # frozen_string_literal: true -# rubocop:disable Style/HashSyntax require 'rake/testtask' require './require_app' task :print_env do - puts "Environment: #{ENV.fetch('RACK_ENV', 'development')}" + puts "Environment: #{ENV['RACK_ENV'] || 'development'}" end desc 'Run application console (pry)' @@ -41,7 +40,6 @@ end namespace :run do # Run in development mode - desc 'Run Web App in development mode' task :dev do sh 'rackup -p 9292' end @@ -82,4 +80,3 @@ namespace :session do puts "#{wiped.count} sessions deleted" end end -# rubocop:enable Style/HashSyntax diff --git a/app/controllers/auth.rb b/app/controllers/auth.rb index 6dfb3ee..935ba04 100644 --- a/app/controllers/auth.rb +++ b/app/controllers/auth.rb @@ -33,8 +33,7 @@ def gh_oauth_url(config) routing.redirect @login_route end - authenticated = AuthenticateAccount.new(App.config) - .call(**credentials.values) + authenticated = AuthenticateAccount.new.call(**credentials.values) current_account = Account.new( authenticated[:account], diff --git a/app/controllers/security.rb b/app/controllers/security.rb new file mode 100644 index 0000000..8f1b0a0 --- /dev/null +++ b/app/controllers/security.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require_relative './app' +require 'roda' +require 'rack/ssl-enforcer' +require 'secure_headers' + +module Credence + # Configuration for the API + class App < Roda + plugin :environments + plugin :multi_route + + FONT_SRC = %w[https://cdn.jsdelivr.net].freeze + SCRIPT_SRC = %w[https://cdn.jsdelivr.net].freeze + STYLE_SRC = %w[https://bootswatch.com https://cdn.jsdelivr.net].freeze + + configure :production do + use Rack::SslEnforcer, hsts: true + end + + ## Uncomment to drop the login session in case of any violation + # use Rack::Protection, reaction: :drop_session + use SecureHeaders::Middleware + + SecureHeaders::Configuration.default do |config| + config.cookies = { + secure: true, + httponly: true, + samesite: { + strict: true + } + } + + config.x_frame_options = 'DENY' + config.x_content_type_options = 'nosniff' + config.x_xss_protection = '1' + config.x_permitted_cross_domain_policies = 'none' + config.referrer_policy = 'origin-when-cross-origin' + + # note: single-quotes needed around 'self' and 'none' in CSPs + # rubocop:disable Lint/PercentStringArray + config.csp = { + report_only: false, + preserve_schemes: true, + default_src: %w['self'], + child_src: %w['self'], + connect_src: %w[wws:], + img_src: %w['self'], + font_src: %w['self'] + FONT_SRC, + script_src: %w['self'] + SCRIPT_SRC, + style_src: %W['self'] + STYLE_SRC, + form_action: %w['self'], + frame_ancestors: %w['none'], + object_src: %w['none'], + block_all_mixed_content: true, + report_uri: %w[/security/report_csp_violation] + } + # rubocop:enable Lint/PercentStringArray + end + + route('security') do |routing| + # POST security/report_csp_violation + routing.post 'report_csp_violation' do + App.logger.warn "CSP VIOLATION: #{request.body.read}" + end + end + end +end diff --git a/app/presentation/views/layout.slim b/app/presentation/views/layout.slim index 57f6c2a..613cbff 100644 --- a/app/presentation/views/layout.slim +++ b/app/presentation/views/layout.slim @@ -6,7 +6,7 @@ html / Themed Bootstrap CSS (Cerulean Theme) - see bootswatch.com for more themes / - default Bootstrap CSS: https://cdn.jsdelivr.net/npm/bootstrap@5.0.0/dist/css/bootstrap.min.css link rel="shortcut icon" href="#" - link rel="stylesheet" href="https://bootswatch.com/5/cerulean/bootstrap.min.css" integrity="sha384-QIHD79FAZ7/24rhhTveEfwJuiAeoPTOQpcVTWsfuPqh3JhPaV6LW8H0cTshmT0Jn" crossorigin="anonymous" + link rel="stylesheet" href="https://bootswatch.com/5/cerulean/bootstrap.min.css" integrity="sha384-6PpWmaLt7GOhVYBzVUBXgwhaV2S6iHthYodyuX2lCQlqAmt6RHXOh+wbLF6hunfO" crossorigin="anonymous" link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/FortAwesome/Font-Awesome@5.15.3/css/all.min.css" integrity="sha384-SZXxX4whJ79/gErwcOYf+zWLeJdY/qpuqC4cAa9rOGUstPomtqpuNWT9wdPEn2fk" crossorigin="anonymous" / Custom CSS diff --git a/config/environments.rb b/config/environments.rb index 2edbff4..417c049 100644 --- a/config/environments.rb +++ b/config/environments.rb @@ -16,28 +16,25 @@ class App < Roda # Environment variables setup Figaro.application = Figaro::Application.new( - environment:, + environment: environment, path: File.expand_path('config/secrets.yml') ) Figaro.load - def self.config = Figaro.env + def self.config() = Figaro.env # Logger setup LOGGER = Logger.new($stderr) - def self.logger = LOGGER + def self.logger() = LOGGER ONE_MONTH = 30 * 24 * 60 * 60 configure do + SecureSession.setup(ENV['REDIS_TLS_URL']) # REDIS_TLS_URL used again below SecureMessage.setup(ENV.delete('MSG_KEY')) SignedMessage.setup(config) end configure :production do - SecureSession.setup(ENV.fetch('REDIS_TLS_URL')) # REDIS_TLS_URL used again below - - use Rack::SslEnforcer, hsts: true - use Rack::Session::Redis, expire_after: ONE_MONTH, httponly: true, @@ -49,16 +46,17 @@ def self.logger = LOGGER end configure :development, :test do - require 'pry' - - # NOTE: env var REDIS_URL only used to wipe the session store (ok to be nil) - SecureSession.setup(ENV.fetch('REDIS_URL', nil)) # REDIS_URL used again below - # use Rack::Session::Cookie, - # expire_after: ONE_MONTH, secret: config.SESSION_SECRET + # expire_after: ONE_MONTH, + # secret: config.SESSION_SECRET, + # httponly: true, + # same_site: :strict + use Rack::Session::Pool, - expire_after: ONE_MONTH + expire_after: ONE_MONTH, + httponly: true, + same_site: :strict # use Rack::Session::Redis, # expire_after: ONE_MONTH, @@ -73,7 +71,9 @@ def self.logger = LOGGER require 'pry' # Allows running reload! in pry to restart entire app - def self.reload! = exec 'pry -r ./spec/test_load_all' + def self.reload! + exec 'pry -r ./spec/test_load_all' + end end end end