diff --git a/lib/datadog/appsec/configuration/settings.rb b/lib/datadog/appsec/configuration/settings.rb index 867ef19c102..9ad3f9ac85a 100644 --- a/lib/datadog/appsec/configuration/settings.rb +++ b/lib/datadog/appsec/configuration/settings.rb @@ -25,7 +25,7 @@ def self.extended(base) add_settings!(base) end - # rubocop:disable Metrics/AbcSize,Metrics/MethodLength,Metrics/BlockLength + # rubocop:disable Metrics/AbcSize,Metrics/MethodLength,Metrics/BlockLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity def self.add_settings!(base) base.class_eval do settings :appsec do @@ -95,6 +95,46 @@ def self.add_settings!(base) o.default DEFAULT_OBFUSCATOR_VALUE_REGEX end + settings :block do + settings :templates do + option :html do |o| + o.env 'DD_APPSEC_HTTP_BLOCKED_TEMPLATE_HTML' + o.type :string, nilable: true + o.setter do |value| + if value + raise(ArgumentError, "appsec.templates.html: file not found: #{value}") unless File.exist?(value) + + File.open(value, 'rb', &:read) || '' + end + end + end + + option :json do |o| + o.env 'DD_APPSEC_HTTP_BLOCKED_TEMPLATE_JSON' + o.type :string, nilable: true + o.setter do |value| + if value + raise(ArgumentError, "appsec.templates.json: file not found: #{value}") unless File.exist?(value) + + File.open(value, 'rb', &:read) || '' + end + end + end + + option :text do |o| + o.env 'DD_APPSEC_HTTP_BLOCKED_TEMPLATE_TEXT' + o.type :string, nilable: true + o.setter do |value| + if value + raise(ArgumentError, "appsec.templates.text: file not found: #{value}") unless File.exist?(value) + + File.open(value, 'rb', &:read) || '' + end + end + end + end + end + settings :track_user_events do option :enabled do |o| o.default true @@ -132,7 +172,7 @@ def self.add_settings!(base) end end end - # rubocop:enable Metrics/AbcSize,Metrics/MethodLength,Metrics/BlockLength + # rubocop:enable Metrics/AbcSize,Metrics/MethodLength,Metrics/BlockLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity end end end diff --git a/lib/datadog/appsec/response.rb b/lib/datadog/appsec/response.rb index dae3e5d30d3..8347d213249 100644 --- a/lib/datadog/appsec/response.rb +++ b/lib/datadog/appsec/response.rb @@ -33,10 +33,13 @@ def negotiate(env) Datadog.logger.debug { "negotiated response content type: #{content_type}" } + body = [] + body << content(content_type) + Response.new( status: 403, headers: { 'Content-Type' => content_type }, - body: [Datadog::AppSec::Assets.blocked(format: CONTENT_TYPE_TO_FORMAT[content_type])] + body: body, ) end @@ -67,6 +70,18 @@ def content_type(env) rescue Datadog::AppSec::Utils::HTTP::MediaRange::ParseError DEFAULT_CONTENT_TYPE end + + def content(content_type) + content_format = CONTENT_TYPE_TO_FORMAT[content_type] + + using_default = Datadog.configuration.appsec.block.templates.using_default?(content_format) + + if using_default + Datadog::AppSec::Assets.blocked(format: content_format) + else + Datadog.configuration.appsec.block.templates.send(content_format) + end + end end end end diff --git a/sig/datadog/appsec/response.rbs b/sig/datadog/appsec/response.rbs index 66569537d42..98d5b0764b5 100644 --- a/sig/datadog/appsec/response.rbs +++ b/sig/datadog/appsec/response.rbs @@ -3,9 +3,10 @@ module Datadog class Response attr_reader status: ::Integer attr_reader headers: ::Hash[::String, ::String] - attr_reader body: ::Array[untyped] + attr_reader body: ::Array[::String] - def initialize: (status: ::Integer, ?headers: ::Hash[::String, ::String], ?body: ::Array[untyped]) -> void + + def initialize: (status: ::Integer, ?headers: ::Hash[::String, ::String], ?body: ::Array[::String]) -> void def to_rack: () -> ::Array[untyped] def to_sinatra_response: () -> ::Sinatra::Response def to_action_dispatch_response: () -> ::ActionDispatch::Response @@ -17,8 +18,8 @@ module Datadog CONTENT_TYPE_TO_FORMAT: ::Hash[::String, ::Symbol] DEFAULT_CONTENT_TYPE: ::String - def self.format: (::Hash[untyped, untyped] env) -> ::Symbol def self.content_type: (::Hash[untyped, untyped] env) -> ::String + def self.content: (::String) -> ::String end end end diff --git a/sig/datadog/core/configuration/settings.rbs b/sig/datadog/core/configuration/settings.rbs index 44c8807ae1f..1bd02ac0c9b 100644 --- a/sig/datadog/core/configuration/settings.rbs +++ b/sig/datadog/core/configuration/settings.rbs @@ -43,7 +43,25 @@ module Datadog def ruleset=: (String | Symbol | File | StringIO | ::Hash[untyped, untyped]) -> void - def using_default?: (Symbol option) -> bool + def block: () -> _AppSecBlock + end + + interface _AppSecBlock + def templates: () -> _TemplatesBlock + end + + interface _TemplatesBlock + def html=: (::String) -> void + + def html: () -> ::String + + def json=: (::String) -> void + + def json: () -> ::String + + def text=: (::String) -> void + + def text: () -> ::String end def initialize: (*untyped _) -> untyped diff --git a/spec/datadog/appsec/configuration/settings_spec.rb b/spec/datadog/appsec/configuration/settings_spec.rb index b3e7e872394..9427b44a23b 100644 --- a/spec/datadog/appsec/configuration/settings_spec.rb +++ b/spec/datadog/appsec/configuration/settings_spec.rb @@ -559,5 +559,79 @@ def patcher end end end + + describe 'block' do + describe 'templates' do + [ + { method_name: :html, env_var: 'DD_APPSEC_HTTP_BLOCKED_TEMPLATE_HTML' }, + { method_name: :json, env_var: 'DD_APPSEC_HTTP_BLOCKED_TEMPLATE_JSON' }, + { method_name: :text, env_var: 'DD_APPSEC_HTTP_BLOCKED_TEMPLATE_TEXT' } + ].each do |test_info| + describe "##{test_info[:method_name]}" do + context "when #{test_info[:env_var]}" do + subject(:template) { settings.appsec.block.templates.send(test_info[:method_name]) } + + around do |example| + ClimateControl.modify(test_info[:env_var] => template_path) do + example.run + end + end + + context 'is defined and the file exists' do + before do + File.write(template_path, 'testing') + end + + after do + File.delete(template_path) + end + + let(:template_path) do + "hello.#{test_info[:method_name]}" + end + + it { is_expected.to eq 'testing' } + end + + context 'is defined and the file do not exists' do + let(:template_path) do + "hello.#{test_info[:method_name]}" + end + + it { expect { is_expected }.to raise_error(ArgumentError) } + end + end + end + + describe "##{test_info[:method_name]}=" do + subject(:template) { settings.appsec.block.templates.send("#{test_info[:method_name]}=", template_path) } + + context 'is defined and the file exists' do + before do + File.write(template_path, 'testing') + end + + after do + File.delete(template_path) + end + + let(:template_path) do + "hello.#{test_info[:method_name]}" + end + + it { is_expected.to eq 'testing' } + end + + context 'is defined and the file do not exists' do + let(:template_path) do + "hello.#{test_info[:method_name]}" + end + + it { expect { is_expected }.to raise_error(ArgumentError) } + end + end + end + end + end end end diff --git a/spec/datadog/appsec/response_spec.rb b/spec/datadog/appsec/response_spec.rb index 5bc89c875c3..e8d742486d4 100644 --- a/spec/datadog/appsec/response_spec.rb +++ b/spec/datadog/appsec/response_spec.rb @@ -10,7 +10,7 @@ end describe '.status' do - subject(:content_type) { described_class.negotiate(env).status } + subject(:status) { described_class.negotiate(env).status } it { is_expected.to eq 403 } end @@ -23,22 +23,48 @@ expect(env).to receive(:[]).with('HTTP_ACCEPT').and_return(accept) end + shared_examples_for 'with custom response body' do |type| + before do + File.write("test.#{type}", 'testing') + Datadog.configuration.appsec.block.templates.send("#{type}=", "test.#{type}") + end + + after do + File.delete("test.#{type}") + Datadog.configuration.appsec.reset! + end + + it { is_expected.to eq ['testing'] } + end + + context 'with unsupported Accept headers' do + let(:accept) { 'application/xml' } + + it { is_expected.to eq [Datadog::AppSec::Assets.blocked(format: :json)] } + end + context('with Accept: text/html') do let(:accept) { 'text/html' } it { is_expected.to eq [Datadog::AppSec::Assets.blocked(format: :html)] } + + it_behaves_like 'with custom response body', :html end context('with Accept: application/json') do let(:accept) { 'application/json' } it { is_expected.to eq [Datadog::AppSec::Assets.blocked(format: :json)] } + + it_behaves_like 'with custom response body', :json end context('with Accept: text/plain') do let(:accept) { 'text/plain' } it { is_expected.to eq [Datadog::AppSec::Assets.blocked(format: :text)] } + + it_behaves_like 'with custom response body', :text end end