SECURITY: Add limits for themes and theme assets
This commit adds limits to themes and theme components on the: - file size of about.json and .discourse-compatibility - file size of theme assets - number of files in a theme
This commit is contained in:
parent
290306a932
commit
6f782d8e45
|
@ -27,6 +27,11 @@ class RemoteTheme < ActiveRecord::Base
|
||||||
GITHUB_REGEXP = %r{\Ahttps?://github\.com/}
|
GITHUB_REGEXP = %r{\Ahttps?://github\.com/}
|
||||||
GITHUB_SSH_REGEXP = %r{\Assh://git@github\.com:}
|
GITHUB_SSH_REGEXP = %r{\Assh://git@github\.com:}
|
||||||
|
|
||||||
|
MAX_METADATA_FILE_SIZE = Discourse::MAX_METADATA_FILE_SIZE
|
||||||
|
MAX_ASSET_FILE_SIZE = 8.megabytes
|
||||||
|
MAX_THEME_FILE_COUNT = 1024
|
||||||
|
MAX_THEME_SIZE = 256.megabytes
|
||||||
|
|
||||||
has_one :theme, autosave: false
|
has_one :theme, autosave: false
|
||||||
scope :joined_remotes,
|
scope :joined_remotes,
|
||||||
-> {
|
-> {
|
||||||
|
@ -41,12 +46,24 @@ class RemoteTheme < ActiveRecord::Base
|
||||||
allow_nil: true
|
allow_nil: true
|
||||||
|
|
||||||
def self.extract_theme_info(importer)
|
def self.extract_theme_info(importer)
|
||||||
|
if importer.file_size("about.json") > MAX_METADATA_FILE_SIZE
|
||||||
|
raise ImportError.new I18n.t(
|
||||||
|
"themes.import_error.about_json_too_big",
|
||||||
|
limit:
|
||||||
|
ActiveSupport::NumberHelper.number_to_human_size(
|
||||||
|
MAX_METADATA_FILE_SIZE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
json = JSON.parse(importer["about.json"])
|
json = JSON.parse(importer["about.json"])
|
||||||
json.fetch("name")
|
json.fetch("name")
|
||||||
json
|
json
|
||||||
rescue TypeError, JSON::ParserError, KeyError
|
rescue TypeError, JSON::ParserError, KeyError
|
||||||
raise ImportError.new I18n.t("themes.import_error.about_json")
|
raise ImportError.new I18n.t("themes.import_error.about_json")
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def self.update_zipped_theme(
|
def self.update_zipped_theme(
|
||||||
filename,
|
filename,
|
||||||
|
@ -275,8 +292,43 @@ class RemoteTheme < ActiveRecord::Base
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
importer.all_files.each do |filename|
|
all_files = importer.all_files
|
||||||
|
|
||||||
|
if all_files.size > MAX_THEME_FILE_COUNT
|
||||||
|
raise ImportError,
|
||||||
|
I18n.t(
|
||||||
|
"themes.import_error.too_many_files",
|
||||||
|
count: all_files.size,
|
||||||
|
limit: MAX_THEME_FILE_COUNT,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
theme_size = 0
|
||||||
|
|
||||||
|
all_files.each do |filename|
|
||||||
next unless opts = ThemeField.opts_from_file_path(filename)
|
next unless opts = ThemeField.opts_from_file_path(filename)
|
||||||
|
|
||||||
|
file_size = importer.file_size(filename)
|
||||||
|
|
||||||
|
if file_size > MAX_ASSET_FILE_SIZE
|
||||||
|
raise ImportError,
|
||||||
|
I18n.t(
|
||||||
|
"themes.import_error.asset_too_big",
|
||||||
|
filename: filename,
|
||||||
|
limit: ActiveSupport::NumberHelper.number_to_human_size(MAX_ASSET_FILE_SIZE),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
theme_size += file_size
|
||||||
|
|
||||||
|
if theme_size > MAX_THEME_SIZE
|
||||||
|
raise ImportError,
|
||||||
|
I18n.t(
|
||||||
|
"themes.import_error.theme_too_big",
|
||||||
|
limit: ActiveSupport::NumberHelper.number_to_human_size(MAX_THEME_SIZE),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
value = importer[filename]
|
value = importer[filename]
|
||||||
updated_fields << theme.set_field(**opts.merge(value: value))
|
updated_fields << theme.set_field(**opts.merge(value: value))
|
||||||
end
|
end
|
||||||
|
|
|
@ -78,8 +78,11 @@ en:
|
||||||
generic: An error occurred while importing that theme
|
generic: An error occurred while importing that theme
|
||||||
upload: "Error creating upload asset: %{name}. %{errors}"
|
upload: "Error creating upload asset: %{name}. %{errors}"
|
||||||
about_json: "Import Error: about.json does not exist, or is invalid. Are you sure this is a Discourse Theme?"
|
about_json: "Import Error: about.json does not exist, or is invalid. Are you sure this is a Discourse Theme?"
|
||||||
|
about_json_too_big: "Import Error: about.json is bigger than the %{limit} limit."
|
||||||
about_json_values: "about.json contains invalid values: %{errors}"
|
about_json_values: "about.json contains invalid values: %{errors}"
|
||||||
modifier_values: "about.json modifiers contain invalid values: %{errors}"
|
modifier_values: "about.json modifiers contain invalid values: %{errors}"
|
||||||
|
asset_too_big: "Asset %{filename} is bigger than the %{limit} limit"
|
||||||
|
theme_too_big: "Theme is bigger than the %{limit} limit"
|
||||||
git: "Error cloning git repository, access is denied or repository is not found"
|
git: "Error cloning git repository, access is denied or repository is not found"
|
||||||
git_ref_not_found: "Unable to checkout git reference: %{ref}"
|
git_ref_not_found: "Unable to checkout git reference: %{ref}"
|
||||||
git_unsupported_scheme: "Unable to clone git repo: scheme unsupported"
|
git_unsupported_scheme: "Unable to clone git repo: scheme unsupported"
|
||||||
|
@ -88,6 +91,7 @@ en:
|
||||||
unknown_file_type: "The file you uploaded does not appear to be a valid Discourse theme."
|
unknown_file_type: "The file you uploaded does not appear to be a valid Discourse theme."
|
||||||
not_allowed_theme: "`%{repo}` is not in the list of allowed themes (check `allowed_theme_repos` global setting)."
|
not_allowed_theme: "`%{repo}` is not in the list of allowed themes (check `allowed_theme_repos` global setting)."
|
||||||
ssh_key_gone: "You waited too long to install the theme and SSH key expired. Please try again."
|
ssh_key_gone: "You waited too long to install the theme and SSH key expired. Please try again."
|
||||||
|
too_many_files: "The number of files (%{count}) in the theme has exceeded the maximum allowed number of files (%{limit})"
|
||||||
errors:
|
errors:
|
||||||
component_no_user_selectable: "Theme components can't be user-selectable"
|
component_no_user_selectable: "Theme components can't be user-selectable"
|
||||||
component_no_default: "Theme components can't be default theme"
|
component_no_default: "Theme components can't be default theme"
|
||||||
|
|
|
@ -8,6 +8,7 @@ require "version"
|
||||||
module Discourse
|
module Discourse
|
||||||
DB_POST_MIGRATE_PATH ||= "db/post_migrate"
|
DB_POST_MIGRATE_PATH ||= "db/post_migrate"
|
||||||
REQUESTED_HOSTNAME ||= "REQUESTED_HOSTNAME"
|
REQUESTED_HOSTNAME ||= "REQUESTED_HOSTNAME"
|
||||||
|
MAX_METADATA_FILE_SIZE = 64.kilobytes
|
||||||
|
|
||||||
class Utils
|
class Utils
|
||||||
URI_REGEXP ||= URI.regexp(%w[http https])
|
URI_REGEXP ||= URI.regexp(%w[http https])
|
||||||
|
|
|
@ -26,6 +26,12 @@ module ThemeStore
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def file_size(path)
|
||||||
|
fullpath = real_path(path)
|
||||||
|
return -1 unless fullpath
|
||||||
|
File.size(fullpath)
|
||||||
|
end
|
||||||
|
|
||||||
def all_files
|
def all_files
|
||||||
Dir.glob("**/**", base: temp_folder).reject { |f| File.directory?(File.join(temp_folder, f)) }
|
Dir.glob("**/**", base: temp_folder).reject { |f| File.directory?(File.join(temp_folder, f)) }
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,19 +6,18 @@ class ThemeStore::ZipImporter < ThemeStore::BaseImporter
|
||||||
attr_reader :url
|
attr_reader :url
|
||||||
|
|
||||||
def initialize(filename, original_filename)
|
def initialize(filename, original_filename)
|
||||||
@temp_folder = "#{Pathname.new(Dir.tmpdir).realpath}/discourse_theme_#{SecureRandom.hex}"
|
|
||||||
@filename = filename
|
@filename = filename
|
||||||
@original_filename = original_filename
|
@original_filename = original_filename
|
||||||
end
|
end
|
||||||
|
|
||||||
def import!
|
def import!
|
||||||
FileUtils.mkdir(@temp_folder)
|
FileUtils.mkdir(temp_folder)
|
||||||
|
|
||||||
available_size = SiteSetting.decompressed_theme_max_file_size_mb
|
available_size = SiteSetting.decompressed_theme_max_file_size_mb
|
||||||
Compression::Engine
|
Compression::Engine
|
||||||
.engine_for(@original_filename)
|
.engine_for(@original_filename)
|
||||||
.tap do |engine|
|
.tap do |engine|
|
||||||
engine.decompress(@temp_folder, @filename, available_size)
|
engine.decompress(temp_folder, @filename, available_size)
|
||||||
strip_root_directory
|
strip_root_directory
|
||||||
end
|
end
|
||||||
rescue RuntimeError
|
rescue RuntimeError
|
||||||
|
@ -32,9 +31,9 @@ class ThemeStore::ZipImporter < ThemeStore::BaseImporter
|
||||||
end
|
end
|
||||||
|
|
||||||
def strip_root_directory
|
def strip_root_directory
|
||||||
root_files = Dir.glob("#{@temp_folder}/*")
|
root_files = Dir.glob("#{temp_folder}/*")
|
||||||
if root_files.size == 1 && File.directory?(root_files[0])
|
if root_files.size == 1 && File.directory?(root_files[0])
|
||||||
FileUtils.mv(Dir.glob("#{@temp_folder}/*/*"), @temp_folder)
|
FileUtils.mv(Dir.glob("#{temp_folder}/*/*"), temp_folder)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -92,12 +92,37 @@ module Discourse
|
||||||
# Find a compatible resource from a git repo
|
# Find a compatible resource from a git repo
|
||||||
def self.find_compatible_git_resource(path)
|
def self.find_compatible_git_resource(path)
|
||||||
return unless File.directory?("#{path}/.git")
|
return unless File.directory?("#{path}/.git")
|
||||||
compat_resource, std_error, s =
|
|
||||||
Open3.capture3(
|
tree_info =
|
||||||
"git -C '#{path}' show HEAD@{upstream}:#{Discourse::VERSION_COMPATIBILITY_FILENAME}",
|
Discourse::Utils.execute_command(
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
path,
|
||||||
|
"ls-tree",
|
||||||
|
"-l",
|
||||||
|
"HEAD",
|
||||||
|
Discourse::VERSION_COMPATIBILITY_FILENAME,
|
||||||
)
|
)
|
||||||
Discourse.find_compatible_resource(compat_resource) if s.success?
|
blob_size = tree_info.split[3].to_i
|
||||||
|
|
||||||
|
if blob_size > Discourse::MAX_METADATA_FILE_SIZE
|
||||||
|
$stderr.puts "#{Discourse::VERSION_COMPATIBILITY_FILENAME} file in #{path} too big"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
compat_resource =
|
||||||
|
Discourse::Utils.execute_command(
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
path,
|
||||||
|
"show",
|
||||||
|
"HEAD@{upstream}:#{Discourse::VERSION_COMPATIBILITY_FILENAME}",
|
||||||
|
)
|
||||||
|
|
||||||
|
Discourse.find_compatible_resource(compat_resource)
|
||||||
rescue InvalidVersionListError => e
|
rescue InvalidVersionListError => e
|
||||||
$stderr.puts "Invalid version list in #{path}"
|
$stderr.puts "Invalid version list in #{path}"
|
||||||
|
rescue Discourse::Utils::CommandError => e
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -177,5 +177,14 @@ RSpec.describe Discourse::VERSION do
|
||||||
|
|
||||||
expect(output).to include("Invalid version list")
|
expect(output).to include("Invalid version list")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "gracefully handles large .discourse-compatibility files" do
|
||||||
|
stub_const(Discourse, "MAX_METADATA_FILE_SIZE", 1) do
|
||||||
|
output =
|
||||||
|
capture_stderr { expect(Discourse.find_compatible_git_resource(git_directory)).to be_nil }
|
||||||
|
|
||||||
|
expect(output).to include(Discourse::VERSION_COMPATIBILITY_FILENAME)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -195,6 +195,40 @@ RSpec.describe RemoteTheme do
|
||||||
expect(remote.reload.remote_version).to eq(new_version)
|
expect(remote.reload.remote_version).to eq(new_version)
|
||||||
expect(remote.reload.commits_behind).to eq(-1)
|
expect(remote.reload.commits_behind).to eq(-1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "fails if theme has too many files" do
|
||||||
|
stub_const(RemoteTheme, "MAX_THEME_FILE_COUNT", 1) do
|
||||||
|
expect { RemoteTheme.import_theme(initial_repo_url) }.to raise_error(
|
||||||
|
RemoteTheme::ImportError,
|
||||||
|
I18n.t("themes.import_error.too_many_files", count: 14, limit: 1),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "fails if files are too large" do
|
||||||
|
stub_const(RemoteTheme, "MAX_ASSET_FILE_SIZE", 1.byte) do
|
||||||
|
expect { RemoteTheme.import_theme(initial_repo_url) }.to raise_error(
|
||||||
|
RemoteTheme::ImportError,
|
||||||
|
I18n.t(
|
||||||
|
"themes.import_error.asset_too_big",
|
||||||
|
filename: "common/color_definitions.scss",
|
||||||
|
limit: ActiveSupport::NumberHelper.number_to_human_size(1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "fails if theme is too large" do
|
||||||
|
stub_const(RemoteTheme, "MAX_THEME_SIZE", 1.byte) do
|
||||||
|
expect { RemoteTheme.import_theme(initial_repo_url) }.to raise_error(
|
||||||
|
RemoteTheme::ImportError,
|
||||||
|
I18n.t(
|
||||||
|
"themes.import_error.theme_too_big",
|
||||||
|
limit: ActiveSupport::NumberHelper.number_to_human_size(1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
let(:github_repo) do
|
let(:github_repo) do
|
||||||
|
@ -232,6 +266,48 @@ RSpec.describe RemoteTheme do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe ".extract_theme_info" do
|
||||||
|
let(:importer) { mock }
|
||||||
|
|
||||||
|
let(:theme_info) do
|
||||||
|
{
|
||||||
|
"name" => "My Theme",
|
||||||
|
"about_url" => "https://example.com/about",
|
||||||
|
"license_url" => "https://example.com/license",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it "raises an error if about.json is too big" do
|
||||||
|
importer.stubs(:file_size).with("about.json").returns(100_000_000)
|
||||||
|
|
||||||
|
expect { RemoteTheme.extract_theme_info(importer) }.to raise_error(
|
||||||
|
RemoteTheme::ImportError,
|
||||||
|
I18n.t(
|
||||||
|
"themes.import_error.about_json_too_big",
|
||||||
|
limit:
|
||||||
|
ActiveSupport::NumberHelper.number_to_human_size((RemoteTheme::MAX_METADATA_FILE_SIZE)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "raises an error if about.json is invalid" do
|
||||||
|
importer.stubs(:file_size).with("about.json").returns(123)
|
||||||
|
importer.stubs(:[]).with("about.json").returns("{")
|
||||||
|
|
||||||
|
expect { RemoteTheme.extract_theme_info(importer) }.to raise_error(
|
||||||
|
RemoteTheme::ImportError,
|
||||||
|
I18n.t("themes.import_error.about_json"),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns extracted theme info" do
|
||||||
|
importer.stubs(:file_size).with("about.json").returns(123)
|
||||||
|
importer.stubs(:[]).with("about.json").returns(theme_info.to_json)
|
||||||
|
|
||||||
|
expect(RemoteTheme.extract_theme_info(importer)).to eq(theme_info)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe ".joined_remotes" do
|
describe ".joined_remotes" do
|
||||||
it "finds records that are associated with themes" do
|
it "finds records that are associated with themes" do
|
||||||
github_repo
|
github_repo
|
||||||
|
|
Loading…
Reference in New Issue