diff --git a/lib/datadog/appsec/action_handler.rb b/lib/datadog/appsec/action_handler.rb new file mode 100644 index 00000000000..6ca3663057d --- /dev/null +++ b/lib/datadog/appsec/action_handler.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Datadog + module AppSec + # this module encapsulates functions for handling actions that libddawf returns + module ActionHandler + module_function + + def handle(type, action_params) + case type + when 'block_request' then block_request(action_params) + when 'redirect_request' then redirect_request(action_params) + when 'generate_stack' then generate_stack(action_params) + when 'generate_schema' then generate_schema(action_params) + when 'monitor' then monitor(action_params) + else + Datadog.logger.error "Unknown action type: #{type}" + end + end + + def block_request(action_params) + throw(Datadog::AppSec::Ext::INTERRUPT, action_params) + end + + def redirect_request(action_params) + throw(Datadog::AppSec::Ext::INTERRUPT, action_params) + end + + def generate_stack(_action_params); end + + def generate_schema(_action_params); end + + def monitor(_action_params); end + end + end +end diff --git a/lib/datadog/appsec/contrib/rack/request_body_middleware.rb b/lib/datadog/appsec/contrib/rack/request_body_middleware.rb index 3159f228e79..cb45d16328e 100644 --- a/lib/datadog/appsec/contrib/rack/request_body_middleware.rb +++ b/lib/datadog/appsec/contrib/rack/request_body_middleware.rb @@ -24,7 +24,7 @@ def call(env) # TODO: handle exceptions, except for @app.call http_response = nil - block_actions = catch(::Datadog::AppSec::Ext::INTERRUPT) do + block_action_params = catch(::Datadog::AppSec::Ext::INTERRUPT) do http_response, = Instrumentation.gateway.push('rack.request.body', Gateway::Request.new(env)) do @app.call(env) end @@ -32,7 +32,7 @@ def call(env) nil end - return AppSec::Response.negotiate(env, block_actions).to_rack if block_actions + return AppSec::Response.build(block_action_params, env['HTTP_ACCEPT']).to_rack if block_action_params http_response end diff --git a/lib/datadog/appsec/contrib/rack/request_middleware.rb b/lib/datadog/appsec/contrib/rack/request_middleware.rb index 52cfcefc420..6d380422580 100644 --- a/lib/datadog/appsec/contrib/rack/request_middleware.rb +++ b/lib/datadog/appsec/contrib/rack/request_middleware.rb @@ -76,7 +76,7 @@ def call(env) gateway_request = Gateway::Request.new(env) gateway_response = nil - block_actions = catch(::Datadog::AppSec::Ext::INTERRUPT) do + block_action_params = catch(::Datadog::AppSec::Ext::INTERRUPT) do http_response, = Instrumentation.gateway.push('rack.request', gateway_request) do @app.call(env) end @@ -90,7 +90,7 @@ def call(env) nil end - http_response = AppSec::Response.negotiate(env, block_actions).to_rack if block_actions + http_response = AppSec::Response.build(block_action_params, env['HTTP_ACCEPT']).to_rack if block_action_params if AppSec.api_security_enabled? ctx.events << { diff --git a/lib/datadog/appsec/contrib/rails/patcher.rb b/lib/datadog/appsec/contrib/rails/patcher.rb index 479f65355d8..ef972290150 100644 --- a/lib/datadog/appsec/contrib/rails/patcher.rb +++ b/lib/datadog/appsec/contrib/rails/patcher.rb @@ -87,10 +87,11 @@ def process_action(*args) if request_response blocked_event = request_response.find { |action, _options| action == :block } if blocked_event - @_response = AppSec::Response.negotiate( - env, - blocked_event.last[:actions] + @_response = AppSec::Response.build( + blocked_event.last[:actions].values.first, + env['HTTP_ACCEPT'] ).to_action_dispatch_response + request_return = @_response.body end end diff --git a/lib/datadog/appsec/contrib/sinatra/patcher.rb b/lib/datadog/appsec/contrib/sinatra/patcher.rb index be4b4a73107..9f5b5d9378e 100644 --- a/lib/datadog/appsec/contrib/sinatra/patcher.rb +++ b/lib/datadog/appsec/contrib/sinatra/patcher.rb @@ -70,7 +70,11 @@ def dispatch! if request_response blocked_event = request_response.find { |action, _options| action == :block } if blocked_event - self.response = AppSec::Response.negotiate(env, blocked_event.last[:actions]).to_sinatra_response + self.response = AppSec::Response.build( + blocked_event.last[:actions].values.first, + env['HTTP_ACCEPT'] + ).to_sinatra_response + request_return = nil end end @@ -111,7 +115,10 @@ def process_route(*) if request_response blocked_event = request_response.find { |action, _options| action == :block } if blocked_event - self.response = AppSec::Response.negotiate(env, blocked_event.last[:actions]).to_sinatra_response + self.response = AppSec::Response.build( + blocked_event.last[:actions].values.first, + env['HTTP_ACCEPT'] + ).to_sinatra_response # interrupt request and return response to dispatch! for consistency throw(Datadog::AppSec::Contrib::Sinatra::Ext::ROUTE_INTERRUPT, response) diff --git a/lib/datadog/appsec/response.rb b/lib/datadog/appsec/response.rb index 8d63fd88622..cdfb976c9f8 100644 --- a/lib/datadog/appsec/response.rb +++ b/lib/datadog/appsec/response.rb @@ -28,23 +28,10 @@ def to_action_dispatch_response end class << self - def negotiate(env, actions) - # @type var configured_response: Response? - configured_response = nil - actions.each do |type, parameters| - # Need to use next to make steep happy :( - # I rather use break to stop the execution - next if configured_response - - configured_response = case type - when 'block_request' - block_response(env, parameters) - when 'redirect_request' - redirect_response(env, parameters) - end - end + def build(action_params, http_accept_header) + return redirect_response(action_params) if action_params['location'] - configured_response || default_response(env) + block_response(action_params, http_accept_header) end def graphql_response(gateway_multiplex) @@ -63,56 +50,29 @@ def graphql_response(gateway_multiplex) private - def default_response(env) - content_type = content_type(env) - - body = [] - body << content(content_type) + def block_response(action_params, http_accept_header) + content_type = case action_params['type'] + when nil, 'auto' then content_type(http_accept_header) + else FORMAT_TO_CONTENT_TYPE.fetch(action_params['type'], DEFAULT_CONTENT_TYPE) + end Response.new( - status: 403, + status: action_params['status_code']&.to_i || 403, headers: { 'Content-Type' => content_type }, - body: body, + body: [content(content_type)], ) end - def block_response(env, options) - content_type = if options['type'] == 'auto' - content_type(env) - else - FORMAT_TO_CONTENT_TYPE[options['type']] - end - - body = [] - body << content(content_type) + def redirect_response(action_params) + status_code = action_params['status_code'].to_i Response.new( - status: options['status_code']&.to_i || 403, - headers: { 'Content-Type' => content_type }, - body: body, + status: (status_code >= 300 && status_code < 400 ? status_code : 303), + headers: { 'Location' => action_params.fetch('location') }, + body: [], ) end - def redirect_response(env, options) - if options['location'] && !options['location'].empty? - content_type = content_type(env) - - headers = { - 'Content-Type' => content_type, - 'Location' => options['location'] - } - - status_code = options['status_code'].to_i - Response.new( - status: (status_code >= 300 && status_code < 400 ? status_code : 303), - headers: headers, - body: [], - ) - else - default_response(env) - end - end - CONTENT_TYPE_TO_FORMAT = { 'application/json' => :json, 'text/html' => :html, @@ -126,10 +86,10 @@ def redirect_response(env, options) DEFAULT_CONTENT_TYPE = 'application/json' - def content_type(env) - return DEFAULT_CONTENT_TYPE unless env.key?('HTTP_ACCEPT') + def content_type(http_accept_header) + return DEFAULT_CONTENT_TYPE if http_accept_header.nil? - accept_types = env['HTTP_ACCEPT'].split(',').map(&:strip) + accept_types = http_accept_header.split(',').map(&:strip) accepted = accept_types.map { |m| Utils::HTTP::MediaRange.new(m) }.sort!.reverse! diff --git a/sig/datadog/appsec/response.rbs b/sig/datadog/appsec/response.rbs index 3d00dedc5a9..ab401c1a650 100644 --- a/sig/datadog/appsec/response.rbs +++ b/sig/datadog/appsec/response.rbs @@ -11,7 +11,7 @@ module Datadog def to_sinatra_response: () -> ::Sinatra::Response def to_action_dispatch_response: () -> ::ActionDispatch::Response - def self.negotiate: (::Hash[untyped, untyped] env, ::Hash[String, untyped] actions) -> Response + def self.build: (::Hash[::String, ::String] action_params, ::String http_accept_header) -> Response def self.graphql_response: (Datadog::AppSec::Contrib::GraphQL::Gateway::Multiplex gateway_multiplex) -> Array[::GraphQL::Query::Result] private @@ -20,11 +20,10 @@ module Datadog FORMAT_TO_CONTENT_TYPE: ::Hash[::String, ::String] DEFAULT_CONTENT_TYPE: ::String - def self.default_response: (::Hash[untyped, untyped] env) -> Response - def self.block_response: (::Hash[untyped, untyped] env, ::Hash[String, untyped] options) -> Response - def self.redirect_response: (::Hash[untyped, untyped] env, ::Hash[String, untyped] options) -> Response + def self.block_response: (::Hash[::String, ::String] action_params, ::String http_accept_header) -> Response + def self.redirect_response: (::Hash[::String, ::String] action_params) -> Response - def self.content_type: (::Hash[untyped, untyped] env) -> ::String + def self.content_type: (::String) -> ::String def self.content: (::String) -> ::String end end diff --git a/spec/datadog/appsec/response_spec.rb b/spec/datadog/appsec/response_spec.rb index 0872ae57060..2965ca89911 100644 --- a/spec/datadog/appsec/response_spec.rb +++ b/spec/datadog/appsec/response_spec.rb @@ -1,22 +1,15 @@ require 'datadog/appsec/response' RSpec.describe Datadog::AppSec::Response do - describe '.negotiate' do - let(:env) { double } + describe '.build' do + let(:http_accept_header) { 'text/html' } - before do - allow(env).to receive(:key?).with('HTTP_ACCEPT').and_return(true) - allow(env).to receive(:[]).with('HTTP_ACCEPT').and_return('text/html') - end - - describe 'configured actions' do + describe 'configured action_params' do describe 'block' do - let(:actions) do + let(:action_params) do { - 'block_request' => { - 'type' => type, - 'status_code' => status_code - } + 'type' => type, + 'status_code' => status_code } end @@ -24,7 +17,7 @@ let(:status_code) { '100' } context 'status_code' do - subject(:status) { described_class.negotiate(env, actions).status } + subject(:status) { described_class.build(action_params, http_accept_header).status } it { is_expected.to eq 100 } @@ -36,42 +29,34 @@ end context 'body' do - subject(:body) { described_class.negotiate(env, actions).body } + subject(:body) { described_class.build(action_params, http_accept_header).body } it { is_expected.to eq [Datadog::AppSec::Assets.blocked(format: :html)] } context 'type is auto it uses the HTTP_ACCEPT to decide the result' do let(:type) { 'auto' } - - before do - expect(env).to receive(:key?).with('HTTP_ACCEPT').and_return(true) - expect(env).to receive(:[]).with('HTTP_ACCEPT').and_return('application/json') - end + let(:http_accept_header) { 'application/json' } it { is_expected.to eq [Datadog::AppSec::Assets.blocked(format: :json)] } end end context 'headers' do - subject(:header) { described_class.negotiate(env, actions).headers['Content-Type'] } + subject(:header) { described_class.build(action_params, http_accept_header).headers['Content-Type'] } it { is_expected.to eq 'text/html' } context 'type is auto it uses the HTTP_ACCEPT to decide the result' do let(:type) { 'auto' } - - before do - expect(env).to receive(:key?).with('HTTP_ACCEPT').and_return(true) - expect(env).to receive(:[]).with('HTTP_ACCEPT').and_return('application/json') - end + let(:http_accept_header) { 'application/json' } it { is_expected.to eq 'application/json' } end end - context 'no configured actions' do - let(:actions) { {} } - subject(:response) { described_class.negotiate(env, actions) } + context 'empty action_params' do + let(:action_params) { {} } + subject(:response) { described_class.build(action_params, http_accept_header) } it 'uses default response' do expect(response.status).to eq 403 @@ -82,12 +67,10 @@ end describe 'redirect_request' do - let(:actions) do + let(:action_params) do { - 'redirect_request' => { - 'location' => location, - 'status_code' => status_code - } + 'location' => location, + 'status_code' => status_code } end @@ -95,7 +78,7 @@ let(:status_code) { '303' } context 'status_code' do - subject(:status) { described_class.negotiate(env, actions).status } + subject(:status) { described_class.build(action_params, http_accept_header).status } it { is_expected.to eq 303 } @@ -107,24 +90,13 @@ end context 'body' do - subject(:body) { described_class.negotiate(env, actions).body } + subject(:body) { described_class.build(action_params, http_accept_header).body } it { is_expected.to eq [] } end context 'headers' do - subject(:headers) { described_class.negotiate(env, actions).headers } - - context 'Content-Type' do - before do - expect(env).to receive(:key?).with('HTTP_ACCEPT').and_return(true) - expect(env).to receive(:[]).with('HTTP_ACCEPT').and_return('application/json') - end - - it 'uses the one from HTTP_ACCEPT header' do - expect(headers['Content-Type']).to eq('application/json') - end - end + subject(:headers) { described_class.build(action_params, http_accept_header).headers } context 'Location' do it 'uses the one from the configuration' do @@ -132,34 +104,17 @@ end end end - - context 'location is empty' do - let(:location) { '' } - - subject(:response) { described_class.negotiate(env, actions) } - - it 'uses default response' do - expect(response.status).to eq 403 - expect(response.body).to eq [Datadog::AppSec::Assets.blocked(format: :html)] - expect(response.headers['Content-Type']).to eq 'text/html' - end - end end end describe '.status' do - subject(:status) { described_class.negotiate(env, {}).status } + subject(:status) { described_class.build({}, http_accept_header).status } it { is_expected.to eq 403 } end describe '.body' do - subject(:body) { described_class.negotiate(env, {}).body } - - before do - expect(env).to receive(:key?).with('HTTP_ACCEPT').and_return(true) - expect(env).to receive(:[]).with('HTTP_ACCEPT').and_return(accept) - end + subject(:body) { described_class.build({}, http_accept_header).body } shared_examples_for 'with custom response body' do |type| before do @@ -176,13 +131,13 @@ end context 'with unsupported Accept headers' do - let(:accept) { 'application/xml' } + let(:http_accept_header) { 'application/xml' } it { is_expected.to eq [Datadog::AppSec::Assets.blocked(format: :json)] } end context('with Accept: text/html') do - let(:accept) { 'text/html' } + let(:http_accept_header) { 'text/html' } it { is_expected.to eq [Datadog::AppSec::Assets.blocked(format: :html)] } @@ -190,7 +145,7 @@ end context('with Accept: application/json') do - let(:accept) { 'application/json' } + let(:http_accept_header) { 'application/json' } it { is_expected.to eq [Datadog::AppSec::Assets.blocked(format: :json)] } @@ -198,7 +153,7 @@ end context('with Accept: text/plain') do - let(:accept) { 'text/plain' } + let(:http_accept_header) { 'text/plain' } it { is_expected.to eq [Datadog::AppSec::Assets.blocked(format: :text)] } @@ -207,90 +162,82 @@ end describe ".headers['Content-Type']" do - subject(:content_type) { described_class.negotiate(env, {}).headers['Content-Type'] } - - before do - expect(env).to receive(:key?).with('HTTP_ACCEPT').and_return(respond_to?(:accept)) - - if respond_to?(:accept) - expect(env).to receive(:[]).with('HTTP_ACCEPT').and_return(accept) - else - expect(env).to_not receive(:[]).with('HTTP_ACCEPT') - end - end + subject(:content_type) { described_class.build({}, http_accept_header).headers['Content-Type'] } context('with Accept: text/html') do - let(:accept) { 'text/html' } + let(:http_accept_header) { 'text/html' } - it { is_expected.to eq accept } + it { is_expected.to eq http_accept_header } end context('with Accept: application/json') do - let(:accept) { 'application/json' } + let(:http_accept_header) { 'application/json' } - it { is_expected.to eq accept } + it { is_expected.to eq http_accept_header } end context('with Accept: text/plain') do - let(:accept) { 'text/plain' } + let(:http_accept_header) { 'text/plain' } - it { is_expected.to eq accept } + it { is_expected.to eq http_accept_header } end context('without Accept header') do + let(:http_accept_header) { nil } + it { is_expected.to eq 'application/json' } end context('with Accept: */*') do - let(:accept) { '*/*' } + let(:http_accept_header) { '*/*' } it { is_expected.to eq 'application/json' } end context('with Accept: text/*') do - let(:accept) { 'text/*' } + let(:http_accept_header) { 'text/*' } it { is_expected.to eq 'text/html' } end context('with Accept: application/*') do - let(:accept) { 'application/*' } + let(:http_accept_header) { 'application/*' } it { is_expected.to eq 'application/json' } end context('with unparseable Accept header') do - let(:accept) { 'invalid' } + let(:http_accept_header) { 'invalid' } it { is_expected.to eq 'application/json' } end context('with Accept: text/*;q=0.7, application/*;q=0.8, */*;q=0.9') do - let(:accept) { 'text/*;q=0.7, application/*;q=0.8, */*;q=0.9' } + let(:http_accept_header) { 'text/*;q=0.7, application/*;q=0.8, */*;q=0.9' } it { is_expected.to eq 'application/json' } end context('with unsupported Accept header') do - let(:accept) { 'image/webp' } + let(:http_accept_header) { 'image/webp' } it { is_expected.to eq 'application/json' } end context('with Mozilla Firefox Accept') do - let(:accept) { 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8' } + let(:http_accept_header) { 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8' } it { is_expected.to eq 'text/html' } end context('with Google Chrome Accept') do - let(:accept) { 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7' } # rubocop:disable Layout/LineLength + let(:http_accept_header) { 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7' } # rubocop:disable Layout/LineLength it { is_expected.to eq 'text/html' } end context('with Apple Safari Accept') do - let(:accept) { 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' } + let(:http_accept_header) { 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' } it { is_expected.to eq 'text/html' } end