FIX: Rate limit and hijack certificate generation. (#8215)

To eliminate a DDOS attack vector, we're taking the following measures:

The endpoint will be rate-limited to 3 requests every 60 seconds (per user).
A 24 hours max-age cache header is sent with the response.
The route will be hijacked to generate the certificate in the background.
This commit is contained in:
Roman Rizzi 2019-10-21 13:14:15 -03:00 committed by romanrizzi
parent 5bcc1c1cd5
commit 3a73f29928
4 changed files with 53 additions and 39 deletions

View File

@ -2,8 +2,9 @@
module DiscourseNarrativeBot module DiscourseNarrativeBot
class CertificateGenerator class CertificateGenerator
def initialize(user, date) def initialize(user, date, avatar_url)
@user = user @user = user
@avatar_url = avatar_url
date = date =
begin begin
@ -21,29 +22,36 @@ module DiscourseNarrativeBot
end end
def new_user_track def new_user_track
width = 538.583 # Default width for the SVG svg_default_width = 538.583
ApplicationController.render(inline: File.read(File.expand_path('../templates/new_user.svg.erb', __FILE__)), logo_container = logo_group(55, svg_default_width, 350)
assigns: { width: width,
discobot_user: @discobot_user, ApplicationController.render(inline: read_template('new_user'), assigns: assign_options(svg_default_width, logo_container))
date: @date,
avatar_url: base64_image_link(avatar_url),
logo_group: logo_group(55, width, 350),
name: name })
end end
def advanced_user_track def advanced_user_track
width = 722.8 # Default width for the SVG svg_default_width = 722.8
ApplicationController.render(inline: File.read(File.expand_path('../templates/advanced_user.svg.erb', __FILE__)), logo_container = logo_group(40, svg_default_width, 280)
assigns: { width: width,
discobot_user: @discobot_user, ApplicationController.render(inline: read_template('advanced_user'), assigns: assign_options(svg_default_width, logo_container))
date: @date,
avatar_url: base64_image_link(avatar_url),
logo_group: logo_group(40, width, 280),
name: name })
end end
private private
def read_template(filename)
File.read(File.expand_path("../templates/#{filename}.svg.erb", __FILE__))
end
def assign_options(width, logo_group)
{
width: width,
discobot_user: @discobot_user,
date: @date,
avatar_url: base64_image_link(@avatar_url),
logo_group: logo_group,
name: name
}
end
def name def name
@user.username.titleize @user.username.titleize
end end
@ -84,9 +92,5 @@ module DiscourseNarrativeBot
rescue OpenURI::HTTPError rescue OpenURI::HTTPError
# Ignore if fetching image returns a non 200 response # Ignore if fetching image returns a non 200 response
end end
def avatar_url
UrlHelper.absolute(Discourse.base_uri + @user.avatar_template.gsub('{size}', '250'))
end
end end
end end

View File

@ -80,32 +80,39 @@ after_initialize do
requires_login requires_login
def generate def generate
unless params[:user_id]&.present? immutable_for(24.hours)
raise Discourse::InvalidParameters.new('user_id must be present')
%i[date user_id].each do |key|
raise Discourse::InvalidParameters.new("#{key} must be present") unless params[key]&.present?
end end
rate_limiter = RateLimiter.new(current_user, 'svg_certificate', 3, 1.minute)
rate_limiter.performed! unless current_user.staff?
user = User.find_by(id: params[:user_id]) user = User.find_by(id: params[:user_id])
raise Discourse::NotFound if user.blank? raise Discourse::NotFound if user.blank?
cdn_avatar_url = fetch_avatar_url(user)
unless params[:date]&.present? hijack do
raise Discourse::InvalidParameters.new('date must be present') generator = CertificateGenerator.new(user, params[:date], cdn_avatar_url)
end
generator = CertificateGenerator.new(user, params[:date]) svg = params[:type] == 'advanced' ? generator.advanced_user_track : generator.new_user_track
svg =
case params[:type]
when 'advanced'
generator.advanced_user_track
else
generator.new_user_track
end
respond_to do |format| respond_to do |format|
format.svg { render inline: svg } format.svg { render inline: svg }
end end
end end
end end
private
def fetch_avatar_url(user)
avatar_url = UrlHelper.absolute(Discourse.base_uri + user.avatar_template.gsub('{size}', '250'))
URI(avatar_url).open('rb', redirect: false).read
rescue OpenURI::HTTPError
# Ignore if fetching image returns a non 200 response
end
end
end end
DiscourseNarrativeBot::Engine.routes.draw do DiscourseNarrativeBot::Engine.routes.draw do

View File

@ -4,10 +4,12 @@ require 'rails_helper'
RSpec.describe DiscourseNarrativeBot::CertificateGenerator do RSpec.describe DiscourseNarrativeBot::CertificateGenerator do
let(:user) { Fabricate(:user) } let(:user) { Fabricate(:user) }
let(:avatar_url) { 'http://test.localhost/cdn/avatar.png' }
let(:date) { "2017-00-10" }
describe 'when an invalid date is given' do describe 'when an invalid date is given' do
it 'should default to the current date' do it 'should default to the current date' do
expect { described_class.new(user, "2017-00-10") }.to_not raise_error expect { described_class.new(user, date, avatar_url) }.to_not raise_error
end end
end end
@ -19,7 +21,7 @@ RSpec.describe DiscourseNarrativeBot::CertificateGenerator do
end end
it 'should not try to fetch a image' do it 'should not try to fetch a image' do
expect(described_class.new(user, "2017-02-10").send(:logo_group, 1, 1, 1)) expect(described_class.new(user, date, avatar_url).send(:logo_group, 1, 1, 1))
.to eq(nil) .to eq(nil)
end end
end end

View File

@ -27,7 +27,8 @@ describe "Discobot Certificate" do
end end
it 'should return the right text' do it 'should return the right text' do
stub_request(:get, /letter_avatar_proxy/).to_return(status: 200) stub_request(:get, /letter_avatar_proxy/).to_return(status: 200, body: 'http://test.localhost/cdn/avatar.png')
stub_request(:get, /avatar.png/).to_return(status: 200)
stub_request(:get, SiteSetting.site_logo_small_url) stub_request(:get, SiteSetting.site_logo_small_url)
.to_return(status: 200) .to_return(status: 200)