653 lines
20 KiB
Ruby
653 lines
20 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'spec_helper'
|
|
|
|
RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader, feature_category: :shared do
|
|
let(:policy) { ActionDispatch::ContentSecurityPolicy.new }
|
|
let(:lfs_enabled) { false }
|
|
let(:proxy_download) { false }
|
|
let(:host) { "gdk.test" }
|
|
let(:port) { 3443 }
|
|
|
|
let(:csp_config) do
|
|
{
|
|
enabled: true,
|
|
report_only: false,
|
|
directives: {
|
|
base_uri: 'http://example.com',
|
|
child_src: "'self' https://child.example.com",
|
|
connect_src: "'self' ws://example.com",
|
|
default_src: "'self' https://other.example.com",
|
|
script_src: "'self' https://script.exammple.com ",
|
|
worker_src: "data: https://worker.example.com",
|
|
report_uri: "http://example.com"
|
|
}
|
|
}
|
|
end
|
|
|
|
let(:lfs_config) do
|
|
{
|
|
enabled: lfs_enabled,
|
|
remote_directory: 'lfs-objects',
|
|
connection: object_store_connection_config,
|
|
direct_upload: false,
|
|
proxy_download: proxy_download,
|
|
storage_options: {}
|
|
}
|
|
end
|
|
|
|
let(:object_store_connection_config) do
|
|
{
|
|
provider: 'AWS',
|
|
aws_access_key_id: 'AWS_ACCESS_KEY_ID',
|
|
aws_secret_access_key: 'AWS_SECRET_ACCESS_KEY'
|
|
}
|
|
end
|
|
|
|
before do
|
|
ViteRuby.configure(
|
|
host: host,
|
|
port: port,
|
|
https: true
|
|
)
|
|
stub_lfs_setting(enabled: lfs_enabled)
|
|
allow(LfsObjectUploader)
|
|
.to receive(:object_store_options)
|
|
.and_return(GitlabSettings::Options.build(lfs_config))
|
|
end
|
|
|
|
describe '.default_enabled' do
|
|
let(:enabled) { described_class.default_enabled }
|
|
|
|
it 'is enabled' do
|
|
expect(enabled).to be_truthy
|
|
end
|
|
|
|
context 'when in production' do
|
|
before do
|
|
stub_rails_env('production')
|
|
end
|
|
|
|
it 'is disabled' do
|
|
expect(enabled).to be_falsey
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '.default_directives' do
|
|
let(:directives) { described_class.default_directives }
|
|
let(:child_src) { directives['child_src'] }
|
|
let(:connect_src) { directives['connect_src'] }
|
|
let(:font_src) { directives['font_src'] }
|
|
let(:frame_src) { directives['frame_src'] }
|
|
let(:img_src) { directives['img_src'] }
|
|
let(:media_src) { directives['media_src'] }
|
|
let(:report_uri) { directives['report_uri'] }
|
|
let(:script_src) { directives['script_src'] }
|
|
let(:style_src) { directives['style_src'] }
|
|
let(:worker_src) { directives['worker_src'] }
|
|
|
|
before do
|
|
stub_env('GITLAB_ANALYTICS_URL', nil)
|
|
end
|
|
|
|
it 'returns default directives' do
|
|
directive_names = (described_class::DIRECTIVES - ['report_uri'])
|
|
directive_names.each do |directive|
|
|
expect(directives.has_key?(directive)).to be_truthy
|
|
expect(directives[directive]).to be_truthy
|
|
end
|
|
|
|
expect(directives.has_key?('report_uri')).to be_truthy
|
|
expect(report_uri).to be_nil
|
|
expect(child_src).to eq("#{frame_src} #{worker_src}")
|
|
end
|
|
|
|
describe 'the images-src directive' do
|
|
it 'can be loaded from anywhere' do
|
|
expect(img_src).to include('http: https:')
|
|
end
|
|
end
|
|
|
|
describe 'the media-src directive' do
|
|
it 'can be loaded from anywhere' do
|
|
expect(media_src).to include('http: https:')
|
|
end
|
|
end
|
|
|
|
describe 'the worker-src directive' do
|
|
it 'can be loaded from local origins' do
|
|
expect(worker_src).to eq("'self' http://localhost/assets/ blob: data: https://gdk.test:3443/vite-dev/")
|
|
end
|
|
end
|
|
|
|
describe 'Vite dev server' do
|
|
using RSpec::Parameterized::TableSyntax
|
|
|
|
where(:https, :env) do
|
|
[
|
|
[true, 'development'],
|
|
[true, 'test'],
|
|
[false, 'development'],
|
|
[false, 'test']
|
|
]
|
|
end
|
|
|
|
with_them do
|
|
def protocol
|
|
https ? 'https' : 'http'
|
|
end
|
|
|
|
def ws_protocol
|
|
https ? 'wss' : 'ws'
|
|
end
|
|
|
|
def origin
|
|
"#{protocol}://#{ViteRuby.config.host_with_port}"
|
|
end
|
|
|
|
def dev_server_path
|
|
"#{origin}/vite-dev"
|
|
end
|
|
|
|
def dev_server_socket_path
|
|
"#{ws_protocol}://#{ViteRuby.config.host_with_port}/vite-dev"
|
|
end
|
|
|
|
before do
|
|
ViteRuby.configure(
|
|
host: host,
|
|
port: port,
|
|
https: https
|
|
)
|
|
end
|
|
|
|
context 'when in production' do
|
|
before do
|
|
stub_rails_env('production')
|
|
end
|
|
|
|
it 'does not add directives' do
|
|
expect(connect_src).not_to include(dev_server_path)
|
|
expect(connect_src).not_to include(dev_server_socket_path)
|
|
expect(worker_src).not_to include(dev_server_path)
|
|
expect(style_src).not_to include(dev_server_path)
|
|
expect(font_src).not_to include(dev_server_path)
|
|
end
|
|
end
|
|
|
|
context 'when in non-production' do
|
|
before do
|
|
stub_rails_env(env)
|
|
end
|
|
|
|
it 'adds directives' do
|
|
expect(connect_src).to include(dev_server_path)
|
|
expect(connect_src).to include(dev_server_socket_path)
|
|
expect(worker_src).to include(dev_server_path)
|
|
expect(style_src).to include(dev_server_path)
|
|
expect(font_src).to include(dev_server_path)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'Webpack dev server websocket connections' do
|
|
let(:webpack_dev_server_host) { 'webpack-dev-server.com' }
|
|
let(:webpack_dev_server_port) { '9999' }
|
|
let(:webpack_dev_server_https) { true }
|
|
|
|
before do
|
|
stub_config_setting(
|
|
webpack: { dev_server: {
|
|
host: webpack_dev_server_host,
|
|
webpack_dev_server_port: webpack_dev_server_port,
|
|
https: webpack_dev_server_https
|
|
} }
|
|
)
|
|
end
|
|
|
|
context 'when in production' do
|
|
before do
|
|
stub_rails_env('production')
|
|
end
|
|
|
|
context 'with secure domain' do
|
|
it 'does not include webpack dev server in connect-src' do
|
|
expect(connect_src).not_to include(webpack_dev_server_host)
|
|
expect(connect_src).not_to include(webpack_dev_server_port)
|
|
end
|
|
end
|
|
|
|
context 'with insecure domain' do
|
|
let(:webpack_dev_server_https) { false }
|
|
|
|
it 'does not include webpack dev server in connect-src' do
|
|
expect(connect_src).not_to include(webpack_dev_server_host)
|
|
expect(connect_src).not_to include(webpack_dev_server_port)
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'when in development' do
|
|
before do
|
|
stub_rails_env('development')
|
|
end
|
|
|
|
context 'with secure domain' do
|
|
before do
|
|
stub_config_setting(host: webpack_dev_server_host, port: webpack_dev_server_port, https: true)
|
|
end
|
|
|
|
it 'includes secure websocket url for webpack dev server in connect-src' do
|
|
expect(connect_src).to include("wss://#{webpack_dev_server_host}:#{webpack_dev_server_port}")
|
|
expect(connect_src).not_to include("ws://#{webpack_dev_server_host}:#{webpack_dev_server_port}")
|
|
end
|
|
end
|
|
|
|
context 'with insecure domain' do
|
|
before do
|
|
stub_config_setting(host: webpack_dev_server_host, port: webpack_dev_server_port, https: false)
|
|
end
|
|
|
|
it 'includes insecure websocket url for webpack dev server in connect-src' do
|
|
expect(connect_src).not_to include("wss://#{webpack_dev_server_host}:#{webpack_dev_server_port}")
|
|
expect(connect_src).to include("ws://#{webpack_dev_server_host}:#{webpack_dev_server_port}")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'Websocket connections' do
|
|
it 'with insecure domain' do
|
|
stub_config_setting(host: 'example.com', https: false)
|
|
expect(connect_src).to eq("'self' wss://gdk.test:3443/vite-dev/ https://gdk.test:3443/vite-dev/ ws://example.com")
|
|
end
|
|
|
|
it 'with secure domain' do
|
|
stub_config_setting(host: 'example.com', https: true)
|
|
expect(connect_src).to eq("'self' wss://gdk.test:3443/vite-dev/ https://gdk.test:3443/vite-dev/ wss://example.com")
|
|
end
|
|
|
|
it 'with custom port' do
|
|
stub_config_setting(host: 'example.com', port: '1234')
|
|
expect(connect_src).to eq("'self' wss://gdk.test:3443/vite-dev/ https://gdk.test:3443/vite-dev/ ws://example.com:1234")
|
|
end
|
|
|
|
it 'with custom port and secure domain' do
|
|
stub_config_setting(host: 'example.com', https: true, port: '1234')
|
|
expect(connect_src).to eq("'self' wss://gdk.test:3443/vite-dev/ https://gdk.test:3443/vite-dev/ wss://example.com:1234")
|
|
end
|
|
|
|
it 'when port is included in HTTP_PORTS' do
|
|
described_class::HTTP_PORTS.each do |port|
|
|
stub_config_setting(host: 'example.com', https: true, port: port)
|
|
expect(connect_src).to eq("'self' wss://gdk.test:3443/vite-dev/ https://gdk.test:3443/vite-dev/ wss://example.com")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'LFS connect-src headers' do
|
|
let(:url_for_provider) { described_class.send(:build_lfs_url) }
|
|
|
|
context 'when LFS is enabled' do
|
|
let(:lfs_enabled) { true }
|
|
|
|
context 'and object storage is not in use' do
|
|
let(:lfs_config) do
|
|
{
|
|
enabled: false,
|
|
remote_directory: 'lfs-objects',
|
|
connection: {},
|
|
direct_upload: false,
|
|
proxy_download: true,
|
|
storage_options: {}
|
|
}
|
|
end
|
|
|
|
it 'is expected to be skipped' do
|
|
expect(described_class.send(:allow_lfs, directives)).to be_nil
|
|
expect(connect_src).not_to include('lfs-objects')
|
|
end
|
|
end
|
|
|
|
context 'and direct downloads are enabled' do
|
|
let(:provider) { LfsObjectUploader.object_store_options.connection.provider }
|
|
|
|
context 'when provider is AWS' do
|
|
it { expect(provider).to eq('AWS') }
|
|
|
|
it { expect(url_for_provider).to be_present }
|
|
|
|
it { expect(directives['connect_src']).to include(url_for_provider) }
|
|
end
|
|
|
|
context 'when provider is AzureRM' do
|
|
let(:object_store_connection_config) do
|
|
{
|
|
provider: 'AzureRM',
|
|
azure_storage_account_name: 'azuretest',
|
|
azure_storage_access_key: 'ABCD1234'
|
|
}
|
|
end
|
|
|
|
it { expect(provider).to eq('AzureRM') }
|
|
|
|
it { expect(url_for_provider).to be_present }
|
|
|
|
it { expect(directives['connect_src']).to include(url_for_provider) }
|
|
end
|
|
|
|
context 'when provider is Google' do
|
|
let(:object_store_connection_config) do
|
|
{
|
|
provider: 'Google',
|
|
google_project: 'GOOGLE_PROJECT',
|
|
google_application_default: true
|
|
}
|
|
end
|
|
|
|
it { expect(provider).to eq('Google') }
|
|
|
|
it { expect(url_for_provider).to be_present }
|
|
|
|
it { expect(directives['connect_src']).to include(url_for_provider) }
|
|
end
|
|
end
|
|
|
|
context 'but direct downloads are disabled' do
|
|
let(:proxy_download) { true }
|
|
|
|
it { expect(directives['connect_src']).not_to include(url_for_provider) }
|
|
end
|
|
end
|
|
|
|
context 'when LFS is disabled' do
|
|
let(:proxy_download) { true }
|
|
|
|
it { expect(directives['connect_src']).not_to include(url_for_provider) }
|
|
end
|
|
end
|
|
|
|
describe 'CDN connections' do
|
|
before do
|
|
allow(described_class).to receive(:allow_letter_opener)
|
|
allow(described_class).to receive(:allow_zuora)
|
|
allow(described_class).to receive(:allow_framed_gitlab_paths)
|
|
allow(described_class).to receive(:allow_customersdot)
|
|
allow(described_class).to receive(:csp_level_3_backport)
|
|
end
|
|
|
|
context 'when CDN host is defined' do
|
|
let(:cdn_host) { 'https://cdn.example.com' }
|
|
|
|
before do
|
|
stub_config_setting(cdn_host: cdn_host)
|
|
end
|
|
|
|
it 'adds CDN host to CSP' do
|
|
expect(script_src).to include(cdn_host)
|
|
expect(style_src).to include(cdn_host)
|
|
expect(font_src).to include(cdn_host)
|
|
expect(worker_src).to include(cdn_host)
|
|
expect(frame_src).to include(cdn_host)
|
|
end
|
|
end
|
|
|
|
context 'when CDN host is undefined' do
|
|
before do
|
|
stub_config_setting(cdn_host: nil)
|
|
end
|
|
|
|
it 'does not include CDN host in CSP' do
|
|
expect(script_src).to eq(::Gitlab::ContentSecurityPolicy::Directives.script_src)
|
|
expect(style_src).to eq("#{::Gitlab::ContentSecurityPolicy::Directives.style_src} https://gdk.test:3443/vite-dev/")
|
|
expect(font_src).to eq("'self' https://gdk.test:3443/vite-dev/")
|
|
expect(worker_src).to eq("#{::Gitlab::ContentSecurityPolicy::Directives.worker_src} https://gdk.test:3443/vite-dev/")
|
|
expect(frame_src).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'Zuora directives' do
|
|
context 'when on SaaS', :saas do
|
|
it 'adds Zuora host to CSP' do
|
|
expect(frame_src).to include('https://*.zuora.com/apps/PublicHostedPageLite.do')
|
|
end
|
|
end
|
|
|
|
context 'when is not Gitlab.com?' do
|
|
it 'does not add Zuora host to CSP' do
|
|
expect(frame_src).not_to include('https://*.zuora.com/apps/PublicHostedPageLite.do')
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'when sentry is configured' do
|
|
let(:dsn) { 'dummy://def@sentry.example.com/2' }
|
|
|
|
before do
|
|
stub_config_setting(host: 'gitlab.example.com')
|
|
end
|
|
|
|
context 'when sentry is configured' do
|
|
before do
|
|
allow(Gitlab::CurrentSettings).to receive(:sentry_enabled).and_return(true)
|
|
allow(Gitlab::CurrentSettings).to receive(:sentry_clientside_dsn).and_return(dsn)
|
|
end
|
|
|
|
it 'adds new sentry path to CSP' do
|
|
expect(connect_src).to eq("'self' wss://gdk.test:3443/vite-dev/ https://gdk.test:3443/vite-dev/ ws://gitlab.example.com dummy://sentry.example.com")
|
|
end
|
|
end
|
|
|
|
context 'when sentry settings are from older schemas and sentry setting are missing' do
|
|
before do
|
|
allow(Gitlab::CurrentSettings).to receive(:respond_to?).with(:sentry_enabled).and_return(false)
|
|
allow(Gitlab::CurrentSettings).to receive(:sentry_enabled).and_raise(NoMethodError)
|
|
|
|
allow(Gitlab::CurrentSettings).to receive(:respond_to?).with(:sentry_clientside_dsn).and_return(false)
|
|
allow(Gitlab::CurrentSettings).to receive(:sentry_clientside_dsn).and_raise(NoMethodError)
|
|
end
|
|
|
|
it 'config is backwards compatible, does not add sentry path to CSP' do
|
|
expect(connect_src).to eq("'self' wss://gdk.test:3443/vite-dev/ https://gdk.test:3443/vite-dev/ ws://gitlab.example.com")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'Customer portal frames' do
|
|
context 'when CUSTOMER_PORTAL_URL is set' do
|
|
let(:customer_portal_url) { 'https://customers.example.com' }
|
|
let(:frame_src_expectation) do
|
|
[
|
|
::Gitlab::ContentSecurityPolicy::Directives.frame_src,
|
|
'http://localhost/admin/',
|
|
'http://localhost/assets/',
|
|
'http://localhost/-/speedscope/index.html',
|
|
'http://localhost/-/sandbox/',
|
|
customer_portal_url
|
|
].join(' ')
|
|
end
|
|
|
|
before do
|
|
stub_env('CUSTOMER_PORTAL_URL', customer_portal_url)
|
|
end
|
|
|
|
it 'adds CUSTOMER_PORTAL_URL to CSP' do
|
|
expect(frame_src).to eq(frame_src_expectation)
|
|
end
|
|
end
|
|
|
|
context 'when CUSTOMER_PORTAL_URL is blank' do
|
|
let(:customer_portal_url) { '' }
|
|
let(:frame_src_expectation) do
|
|
[
|
|
::Gitlab::ContentSecurityPolicy::Directives.frame_src,
|
|
'http://localhost/admin/',
|
|
'http://localhost/assets/',
|
|
'http://localhost/-/speedscope/index.html',
|
|
'http://localhost/-/sandbox/'
|
|
].join(' ')
|
|
end
|
|
|
|
before do
|
|
stub_env('CUSTOMER_PORTAL_URL', customer_portal_url)
|
|
end
|
|
|
|
it 'adds CUSTOMER_PORTAL_URL to CSP' do
|
|
expect(frame_src).to eq(frame_src_expectation)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'letter_opener application URL' do
|
|
let(:gitlab_url) { 'http://gitlab.example.com' }
|
|
let(:letter_opener_url) { "#{gitlab_url}/rails/letter_opener/" }
|
|
|
|
before do
|
|
stub_config_setting(url: gitlab_url)
|
|
end
|
|
|
|
context 'when in production' do
|
|
before do
|
|
stub_rails_env('production')
|
|
end
|
|
|
|
it 'does not add letter_opener to CSP' do
|
|
expect(frame_src).not_to include(letter_opener_url)
|
|
end
|
|
end
|
|
|
|
context 'when in development' do
|
|
before do
|
|
stub_rails_env('development')
|
|
end
|
|
|
|
it 'adds letter_opener to CSP' do
|
|
expect(frame_src).to include(letter_opener_url)
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'Snowplow Micro event collector' do
|
|
let(:snowplow_micro_hostname) { 'localhost:9090' }
|
|
let(:snowplow_micro_url) { "http://#{snowplow_micro_hostname}/" }
|
|
|
|
before do
|
|
stub_config(snowplow_micro: { enabled: true })
|
|
allow(Gitlab::Tracking).to receive(:collector_hostname).and_return(snowplow_micro_hostname)
|
|
end
|
|
|
|
context 'when in production' do
|
|
before do
|
|
stub_rails_env('production')
|
|
end
|
|
|
|
it 'does not add Snowplow Micro URL to connect-src' do
|
|
expect(connect_src).not_to include(snowplow_micro_url)
|
|
end
|
|
end
|
|
|
|
context 'when in development' do
|
|
before do
|
|
stub_rails_env('development')
|
|
end
|
|
|
|
it 'adds Snowplow Micro URL with trailing slash to connect-src' do
|
|
expect(connect_src).to match(Regexp.new(snowplow_micro_url))
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'browsersdk_tracking' do
|
|
let(:analytics_url) { 'https://analytics.gitlab.com' }
|
|
let(:is_gitlab_com) { true }
|
|
|
|
before do
|
|
allow(Gitlab).to receive(:com?).and_return(is_gitlab_com)
|
|
end
|
|
|
|
context 'when browsersdk_tracking is enabled, GITLAB_ANALYTICS_URL is set, and Gitlab.com? is true' do
|
|
before do
|
|
stub_env('GITLAB_ANALYTICS_URL', analytics_url)
|
|
end
|
|
|
|
it 'adds GITLAB_ANALYTICS_URL to connect-src' do
|
|
expect(connect_src).to include(analytics_url)
|
|
end
|
|
end
|
|
|
|
context 'when Gitlab.com? is false' do
|
|
let(:is_gitlab_com) { false }
|
|
|
|
before do
|
|
stub_env('GITLAB_ANALYTICS_URL', analytics_url)
|
|
end
|
|
|
|
it 'does not add GITLAB_ANALYTICS_URL to connect-src' do
|
|
expect(connect_src).not_to include(analytics_url)
|
|
end
|
|
end
|
|
|
|
context 'when GITLAB_ANALYTICS_URL is not set' do
|
|
before do
|
|
stub_env('GITLAB_ANALYTICS_URL', nil)
|
|
end
|
|
|
|
it 'does not add GITLAB_ANALYTICS_URL to connect-src' do
|
|
expect(connect_src).not_to include(analytics_url)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#load' do
|
|
let(:default_directives) { described_class.default_directives }
|
|
|
|
subject { described_class.new(csp_config[:directives]) }
|
|
|
|
def expected_config(directive)
|
|
csp_config[:directives][directive].split(' ').map(&:strip)
|
|
end
|
|
|
|
it 'sets the policy properly' do
|
|
subject.load(policy)
|
|
|
|
expect(policy.directives['base-uri']).to eq([csp_config[:directives][:base_uri]])
|
|
expect(policy.directives['default-src']).to eq(expected_config(:default_src))
|
|
expect(policy.directives['connect-src']).to eq(expected_config(:connect_src))
|
|
expect(policy.directives['child-src']).to eq(expected_config(:child_src))
|
|
expect(policy.directives['worker-src']).to eq(expected_config(:worker_src))
|
|
expect(policy.directives['report-uri']).to eq(expected_config(:report_uri))
|
|
end
|
|
|
|
it 'ignores malformed policy statements' do
|
|
csp_config[:directives][:base_uri] = 123
|
|
|
|
subject.load(policy)
|
|
|
|
expect(policy.directives['base-uri']).to be_nil
|
|
end
|
|
|
|
it 'returns default values for directives not defined by the user or with <default_value> and disables directives set to false' do
|
|
# Explicitly disabling script_src and setting report_uri
|
|
csp_config[:directives] = {
|
|
script_src: false,
|
|
style_src: '<default_value>',
|
|
report_uri: 'https://example.org'
|
|
}
|
|
|
|
subject.load(policy)
|
|
|
|
expected_policy = ActionDispatch::ContentSecurityPolicy.new
|
|
# Creating a policy from default settings and manually overriding the custom values
|
|
described_class.new(default_directives).load(expected_policy)
|
|
expected_policy.script_src(nil)
|
|
expected_policy.report_uri('https://example.org')
|
|
|
|
expect(policy.directives).to eq(expected_policy.directives)
|
|
end
|
|
end
|
|
end
|