diff options
22 files changed, 770 insertions, 69 deletions
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index a6d62bd3c62..ffacac07517 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -34,7 +34,7 @@ @media (min-width: map-get($grid-breakpoints, md)) { // The `+11` is to ensure the file header border shows when scrolled - // the bottom of the compare-versions header and the top of the file header - $mr-file-header-top: calc(#{$mr-version-controls-height} + #{$header-height} + #{$mr-tabs-height} + 11); + $mr-file-header-top: calc(#{$mr-version-controls-height} + #{$header-height} + #{$mr-tabs-height} + 11px); position: -webkit-sticky; position: sticky; diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 05eab1a9b43..4ff5c3b5179 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -228,6 +228,10 @@ production: &base # client_id: "YOUR-CLIENT-ID" # client_secret: "YOUR-CLIENT-SECRET" + # File that contains the shared secret key for verifying access for mailroom's incoming_email. + # Default is '.gitlab_mailroom_secret' relative to Rails.root (i.e. root of the GitLab app). + # secret_file: /home/git/gitlab/.gitlab_mailroom_secret + ## Consolidated object store config ## This will only take effect if the object_store sections are not defined ## within the types (e.g. artifacts, lfs, etc.). diff --git a/config/mail_room.yml b/config/mail_room.yml index 895438dcc4e..669925c2390 100644 --- a/config/mail_room.yml +++ b/config/mail_room.yml @@ -1,7 +1,7 @@ :mailboxes: <% require_relative "../lib/gitlab/mail_room" unless defined?(Gitlab::MailRoom) - Gitlab::MailRoom.enabled_configs.each do |config| + Gitlab::MailRoom.enabled_configs.each do |_key, config| %> - :host: <%= config[:host].to_json %> diff --git a/db/migrate/20211126115449_encrypt_static_objects_external_storage_auth_token.rb b/db/migrate/20211126115449_encrypt_static_objects_external_storage_auth_token.rb index 9ce034b0065..92ba54d4c89 100644 --- a/db/migrate/20211126115449_encrypt_static_objects_external_storage_auth_token.rb +++ b/db/migrate/20211126115449_encrypt_static_objects_external_storage_auth_token.rb @@ -13,6 +13,8 @@ class EncryptStaticObjectsExternalStorageAuthToken < Gitlab::Database::Migration ApplicationSetting.reset_column_information ApplicationSetting.encrypted_token_is_null.plaintext_token_is_not_null.find_each do |application_setting| + next if application_setting.static_objects_external_storage_auth_token.empty? + token_encrypted = Gitlab::CryptoHelper.aes256_gcm_encrypt(application_setting.static_objects_external_storage_auth_token) application_setting.update!(static_objects_external_storage_auth_token_encrypted: token_encrypted) end diff --git a/doc/api/deployments.md b/doc/api/deployments.md index 253bc76737b..19656098f4b 100644 --- a/doc/api/deployments.md +++ b/doc/api/deployments.md @@ -375,3 +375,38 @@ It supports the same parameters as the [Merge Requests API](merge_requests.md#li ```shell curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/deployments/42/merge_requests" ``` + +## Approve or Reject a blocked Deployment + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/343864) in GitLab 14.7 [with a flag](../administration/feature_flags.md) named `deployment_approvals`. Disabled by default. This feature is not ready for production use. + +```plaintext +POST /projects/:id/deployments/:deployment_id/approval +``` + +| Attribute | Type | Required | Description | +|-----------------|----------------|----------|-----------------------------------------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user. | +| `deployment_id` | integer | yes | The ID of the deployment. | +| `status` | string | yes | The status of the approval (either `approved` or `rejected`). | + +```shell +curl --data "status=approved" \ + --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/deployments/1/approval" +``` + +Example response: + +```json +{ + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "status": "approved" +} +``` diff --git a/doc/ci/yaml/script.md b/doc/ci/yaml/script.md index d4bdc653775..ca1e79c2395 100644 --- a/doc/ci/yaml/script.md +++ b/doc/ci/yaml/script.md @@ -164,10 +164,14 @@ Second command line. When you omit the `>` or `|` block scalar indicators, GitLab concatenates non-empty lines to form the command. Make sure the lines can run when concatenated. +<!-- vale gitlab.MeaningfulLinkWords = NO --> + [Shell here documents](https://en.wikipedia.org/wiki/Here_document) work with the `|` and `>` operators as well. The example below transliterates lower case letters to upper case: +<!-- vale gitlab.MeaningfulLinkWords = YES --> + ```yaml job: script: diff --git a/lib/api/api.rb b/lib/api/api.rb index dcecaeae558..5984879413f 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -299,6 +299,7 @@ module API mount ::API::Internal::Lfs mount ::API::Internal::Pages mount ::API::Internal::Kubernetes + mount ::API::Internal::MailRoom version 'v3', using: :path do # Although the following endpoints are kept behind V3 namespace, diff --git a/lib/api/deployments.rb b/lib/api/deployments.rb index 80a50ded522..486ff5d89bc 100644 --- a/lib/api/deployments.rb +++ b/lib/api/deployments.rb @@ -165,3 +165,5 @@ module API end end end + +API::Deployments.prepend_mod diff --git a/lib/api/internal/mail_room.rb b/lib/api/internal/mail_room.rb new file mode 100644 index 00000000000..4b23b20ccde --- /dev/null +++ b/lib/api/internal/mail_room.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module API + # This internal endpoint receives webhooks sent from the MailRoom component. + # This component constantly listens to configured email accounts. When it + # finds any incoming email or service desk email, it makes a POST request to + # this endpoint. The target mailbox type is indicated in the request path. + # The email raw content is attached to the request body. + # + # For more information, please visit https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/644 + module Internal + class MailRoom < ::API::Base + feature_category :service_desk + + before do + authenticate_gitlab_mailroom_request! + end + + helpers do + def authenticate_gitlab_mailroom_request! + unauthorized! unless Gitlab::MailRoom::Authenticator.verify_api_request(headers, params[:mailbox_type]) + end + end + + namespace 'internal' do + namespace 'mail_room' do + params do + requires :mailbox_type, type: String, + desc: 'The destination mailbox type configuration. Must either be incoming_email or service_desk_email' + end + post "/*mailbox_type" do + worker = Gitlab::MailRoom.worker_for(params[:mailbox_type]) + begin + worker.perform_async(request.body.read) + rescue Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError => e + status 400 + break { success: false, message: e.message } + end + + status 200 + { success: true } + end + end + end + end + end +end diff --git a/lib/gitlab/jwt_authenticatable.rb b/lib/gitlab/jwt_authenticatable.rb index 1270a148e8d..08d9f69497e 100644 --- a/lib/gitlab/jwt_authenticatable.rb +++ b/lib/gitlab/jwt_authenticatable.rb @@ -13,26 +13,38 @@ module Gitlab module ClassMethods include Gitlab::Utils::StrongMemoize - def decode_jwt_for_issuer(issuer, encoded_message) - JWT.decode( - encoded_message, - secret, - true, - { iss: issuer, verify_iss: true, algorithm: 'HS256' } - ) + def decode_jwt(encoded_message, jwt_secret = secret, issuer: nil, iat_after: nil) + options = { algorithm: 'HS256' } + options = options.merge(iss: issuer, verify_iss: true) if issuer.present? + options = options.merge(verify_iat: true) if iat_after.present? + + decoded_message = JWT.decode(encoded_message, jwt_secret, true, options) + payload = decoded_message[0] + if iat_after.present? + raise JWT::DecodeError, "JWT iat claim is missing" if payload['iat'].blank? + + iat = payload['iat'].to_i + raise JWT::ExpiredSignature, 'Token has expired' if iat < iat_after.to_i + end + + decoded_message end def secret strong_memoize(:secret) do - Base64.strict_decode64(File.read(secret_path).chomp).tap do |bytes| - raise "#{secret_path} does not contain #{SECRET_LENGTH} bytes" if bytes.length != SECRET_LENGTH - end + read_secret(secret_path) + end + end + + def read_secret(path) + Base64.strict_decode64(File.read(path).chomp).tap do |bytes| + raise "#{path} does not contain #{SECRET_LENGTH} bytes" if bytes.length != SECRET_LENGTH end end - def write_secret + def write_secret(path = secret_path) bytes = SecureRandom.random_bytes(SECRET_LENGTH) - File.open(secret_path, 'w:BINARY', 0600) do |f| + File.open(path, 'w:BINARY', 0600) do |f| f.chmod(0600) # If the file already existed, the '0600' passed to 'open' above was a no-op. f.write(Base64.strict_encode64(bytes)) end diff --git a/lib/gitlab/kas.rb b/lib/gitlab/kas.rb index 408b3afc128..ed7787ffc49 100644 --- a/lib/gitlab/kas.rb +++ b/lib/gitlab/kas.rb @@ -11,7 +11,7 @@ module Gitlab class << self def verify_api_request(request_headers) - decode_jwt_for_issuer(JWT_ISSUER, request_headers[INTERNAL_API_REQUEST_HEADER]) + decode_jwt(request_headers[INTERNAL_API_REQUEST_HEADER], issuer: JWT_ISSUER) rescue JWT::DecodeError nil end diff --git a/lib/gitlab/mail_room.rb b/lib/gitlab/mail_room.rb index 75d27ed8cc1..e93a297cee4 100644 --- a/lib/gitlab/mail_room.rb +++ b/lib/gitlab/mail_room.rb @@ -25,7 +25,7 @@ module Gitlab # Email specific configuration which is merged with configuration # fetched from YML config file. - ADDRESS_SPECIFIC_CONFIG = { + MAILBOX_SPECIFIC_CONFIGS = { incoming_email: { queue: 'email_receiver', worker: 'EmailReceiverWorker' @@ -38,7 +38,15 @@ module Gitlab class << self def enabled_configs - @enabled_configs ||= configs.select { |config| enabled?(config) } + @enabled_configs ||= configs.select { |_key, config| enabled?(config) } + end + + def enabled_mailbox_types + enabled_configs.keys.map(&:to_s) + end + + def worker_for(mailbox_type) + MAILBOX_SPECIFIC_CONFIGS.try(:[], mailbox_type.to_sym).try(:[], :worker).try(:safe_constantize) end private @@ -48,7 +56,7 @@ module Gitlab end def configs - ADDRESS_SPECIFIC_CONFIG.keys.map { |key| fetch_config(key) } + MAILBOX_SPECIFIC_CONFIGS.to_h { |key, _value| [key, fetch_config(key)] } end def fetch_config(config_key) @@ -63,7 +71,7 @@ module Gitlab def merged_configs(config_key) yml_config = load_yaml.fetch(config_key, {}) - specific_config = ADDRESS_SPECIFIC_CONFIG.fetch(config_key, {}) + specific_config = MAILBOX_SPECIFIC_CONFIGS.fetch(config_key, {}) DEFAULT_CONFIG.merge(specific_config, yml_config) do |_key, oldval, newval| newval.nil? ? oldval : newval end diff --git a/lib/gitlab/mail_room/authenticator.rb b/lib/gitlab/mail_room/authenticator.rb new file mode 100644 index 00000000000..26ebdca8beb --- /dev/null +++ b/lib/gitlab/mail_room/authenticator.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Gitlab + module MailRoom + class Authenticator + include JwtAuthenticatable + + SecretConfigurationError = Class.new(StandardError) + INTERNAL_API_REQUEST_HEADER = 'Gitlab-Mailroom-Api-Request' + INTERNAL_API_REQUEST_JWT_ISSUER = 'gitlab-mailroom' + + # Only allow token generated within the last 5 minutes + EXPIRATION = 5.minutes + + class << self + def verify_api_request(request_headers, mailbox_type) + mailbox_type = mailbox_type.to_sym + return false if enabled_configs[mailbox_type].blank? + + decode_jwt( + request_headers[INTERNAL_API_REQUEST_HEADER], + secret(mailbox_type), + issuer: INTERNAL_API_REQUEST_JWT_ISSUER, iat_after: Time.current - EXPIRATION + ) + rescue JWT::DecodeError => e + ::Gitlab::AppLogger.warn("Fail to decode MailRoom JWT token: #{e.message}") if Rails.env.development? + + false + end + + def secret(mailbox_type) + strong_memoize("jwt_secret_#{mailbox_type}".to_sym) do + secret_path = enabled_configs[mailbox_type][:secret_file] + raise SecretConfigurationError, "#{mailbox_type}'s secret_file configuration is missing" if secret_path.blank? + + begin + read_secret(secret_path) + rescue StandardError => e + raise SecretConfigurationError, "Fail to read #{mailbox_type}'s secret: #{e.message}" + end + end + end + + def enabled_configs + Gitlab::MailRoom.enabled_configs + end + end + end + end +end diff --git a/lib/gitlab/middleware/multipart.rb b/lib/gitlab/middleware/multipart.rb index a047015e54f..0b7b5e23b75 100644 --- a/lib/gitlab/middleware/multipart.rb +++ b/lib/gitlab/middleware/multipart.rb @@ -116,7 +116,7 @@ module Gitlab jwt_token = params[param_key] raise "Empty JWT param: #{param_key}" if jwt_token.blank? - payload = Gitlab::Workhorse.decode_jwt(jwt_token).first + payload = Gitlab::Workhorse.decode_jwt_with_issuer(jwt_token).first raise "Invalid JWT payload: not a Hash" unless payload.is_a?(Hash) upload_params = payload.fetch(JWT_PARAM_FIXED_KEY, {}) @@ -172,7 +172,7 @@ module Gitlab encoded_message = env.delete(RACK_ENV_KEY) return @app.call(env) if encoded_message.blank? - message = ::Gitlab::Workhorse.decode_jwt(encoded_message)[0] + message = ::Gitlab::Workhorse.decode_jwt_with_issuer(encoded_message)[0] ::Gitlab::Middleware::Multipart::Handler.new(env, message).with_open_files do @app.call(env) diff --git a/lib/gitlab/pages.rb b/lib/gitlab/pages.rb index 98e87e9e915..4e0e5102bec 100644 --- a/lib/gitlab/pages.rb +++ b/lib/gitlab/pages.rb @@ -10,7 +10,7 @@ module Gitlab class << self def verify_api_request(request_headers) - decode_jwt_for_issuer('gitlab-pages', request_headers[INTERNAL_API_REQUEST_HEADER]) + decode_jwt(request_headers[INTERNAL_API_REQUEST_HEADER], issuer: 'gitlab-pages') rescue JWT::DecodeError false end diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index 3a905a2e1c5..19d30daa577 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -203,11 +203,11 @@ module Gitlab end def verify_api_request!(request_headers) - decode_jwt(request_headers[INTERNAL_API_REQUEST_HEADER]) + decode_jwt_with_issuer(request_headers[INTERNAL_API_REQUEST_HEADER]) end - def decode_jwt(encoded_message) - decode_jwt_for_issuer('gitlab-workhorse', encoded_message) + def decode_jwt_with_issuer(encoded_message) + decode_jwt(encoded_message, issuer: 'gitlab-workhorse') end def secret_path diff --git a/spec/config/mail_room_spec.rb b/spec/config/mail_room_spec.rb index 55f8fdd78ba..ec306837361 100644 --- a/spec/config/mail_room_spec.rb +++ b/spec/config/mail_room_spec.rb @@ -18,8 +18,9 @@ RSpec.describe 'mail_room.yml' do result = Gitlab::Popen.popen_with_detail(%W(ruby -rerb -e #{cmd}), absolute_path('config'), vars) output = result.stdout + errors = result.stderr status = result.status - raise "Error interpreting #{mailroom_config_path}: #{output}" unless status == 0 + raise "Error interpreting #{mailroom_config_path}: #{output}\n#{errors}" unless status == 0 YAML.safe_load(output, permitted_classes: [Symbol]) end diff --git a/spec/lib/gitlab/jwt_authenticatable_spec.rb b/spec/lib/gitlab/jwt_authenticatable_spec.rb index 36bb46cb250..92d5feceb75 100644 --- a/spec/lib/gitlab/jwt_authenticatable_spec.rb +++ b/spec/lib/gitlab/jwt_authenticatable_spec.rb @@ -14,17 +14,12 @@ RSpec.describe Gitlab::JwtAuthenticatable do end before do - begin - File.delete(test_class.secret_path) - rescue Errno::ENOENT - end + FileUtils.rm_f(test_class.secret_path) test_class.write_secret end - describe '.secret' do - subject(:secret) { test_class.secret } - + shared_examples 'reading secret from the secret path' do it 'returns 32 bytes' do expect(secret).to be_a(String) expect(secret.length).to eq(32) @@ -32,62 +27,170 @@ RSpec.describe Gitlab::JwtAuthenticatable do end it 'accepts a trailing newline' do - File.open(test_class.secret_path, 'a') { |f| f.write "\n" } + File.open(secret_path, 'a') { |f| f.write "\n" } expect(secret.length).to eq(32) end it 'raises an exception if the secret file cannot be read' do - File.delete(test_class.secret_path) + File.delete(secret_path) expect { secret }.to raise_exception(Errno::ENOENT) end it 'raises an exception if the secret file contains the wrong number of bytes' do - File.truncate(test_class.secret_path, 0) + File.truncate(secret_path, 0) expect { secret }.to raise_exception(RuntimeError) end end + describe '.secret' do + it_behaves_like 'reading secret from the secret path' do + subject(:secret) { test_class.secret } + + let(:secret_path) { test_class.secret_path } + end + end + + describe '.read_secret' do + it_behaves_like 'reading secret from the secret path' do + subject(:secret) { test_class.read_secret(secret_path) } + + let(:secret_path) { test_class.secret_path } + end + end + describe '.write_secret' do - it 'uses mode 0600' do - expect(File.stat(test_class.secret_path).mode & 0777).to eq(0600) + context 'without an input' do + it 'uses mode 0600' do + expect(File.stat(test_class.secret_path).mode & 0777).to eq(0600) + end + + it 'writes base64 data' do + bytes = Base64.strict_decode64(File.read(test_class.secret_path)) + + expect(bytes).not_to be_empty + end end - it 'writes base64 data' do - bytes = Base64.strict_decode64(File.read(test_class.secret_path)) + context 'with an input' do + let(:another_path) do + Rails.root.join('tmp', 'tests', '.jwt_another_shared_secret') + end - expect(bytes).not_to be_empty + after do + File.delete(another_path) + rescue Errno::ENOENT + end + + it 'uses mode 0600' do + test_class.write_secret(another_path) + expect(File.stat(another_path).mode & 0777).to eq(0600) + end + + it 'writes base64 data' do + test_class.write_secret(another_path) + bytes = Base64.strict_decode64(File.read(another_path)) + + expect(bytes).not_to be_empty + end end end - describe '.decode_jwt_for_issuer' do - let(:payload) { { 'iss' => 'test_issuer' } } + describe '.decode_jwt' do |decode| + let(:payload) { {} } + + context 'use included class secret' do + it 'accepts a correct header' do + encoded_message = JWT.encode(payload, test_class.secret, 'HS256') + + expect { test_class.decode_jwt(encoded_message) }.not_to raise_error + end + + it 'raises an error when the JWT is not signed' do + encoded_message = JWT.encode(payload, nil, 'none') + + expect { test_class.decode_jwt(encoded_message) }.to raise_error(JWT::DecodeError) + end - it 'accepts a correct header' do - encoded_message = JWT.encode(payload, test_class.secret, 'HS256') + it 'raises an error when the header is signed with the wrong secret' do + encoded_message = JWT.encode(payload, 'wrongsecret', 'HS256') - expect { test_class.decode_jwt_for_issuer('test_issuer', encoded_message) }.not_to raise_error + expect { test_class.decode_jwt(encoded_message) }.to raise_error(JWT::DecodeError) + end end - it 'raises an error when the JWT is not signed' do - encoded_message = JWT.encode(payload, nil, 'none') + context 'use an input secret' do + let(:another_secret) { 'another secret' } + + it 'accepts a correct header' do + encoded_message = JWT.encode(payload, another_secret, 'HS256') + + expect { test_class.decode_jwt(encoded_message, another_secret) }.not_to raise_error + end - expect { test_class.decode_jwt_for_issuer('test_issuer', encoded_message) }.to raise_error(JWT::DecodeError) + it 'raises an error when the JWT is not signed' do + encoded_message = JWT.encode(payload, nil, 'none') + + expect { test_class.decode_jwt(encoded_message, another_secret) }.to raise_error(JWT::DecodeError) + end + + it 'raises an error when the header is signed with the wrong secret' do + encoded_message = JWT.encode(payload, 'wrongsecret', 'HS256') + + expect { test_class.decode_jwt(encoded_message, another_secret) }.to raise_error(JWT::DecodeError) + end end - it 'raises an error when the header is signed with the wrong secret' do - encoded_message = JWT.encode(payload, 'wrongsecret', 'HS256') + context 'issuer option' do + let(:payload) { { 'iss' => 'test_issuer' } } + + it 'returns decoded payload if issuer is correct' do + encoded_message = JWT.encode(payload, test_class.secret, 'HS256') + payload = test_class.decode_jwt(encoded_message, issuer: 'test_issuer') - expect { test_class.decode_jwt_for_issuer('test_issuer', encoded_message) }.to raise_error(JWT::DecodeError) + expect(payload[0]).to match a_hash_including('iss' => 'test_issuer') + end + + it 'raises an error when the issuer is incorrect' do + payload['iss'] = 'somebody else' + encoded_message = JWT.encode(payload, test_class.secret, 'HS256') + + expect { test_class.decode_jwt(encoded_message, issuer: 'test_issuer') }.to raise_error(JWT::DecodeError) + end end - it 'raises an error when the issuer is incorrect' do - payload['iss'] = 'somebody else' - encoded_message = JWT.encode(payload, test_class.secret, 'HS256') + context 'iat_after option' do + it 'returns decoded payload if iat is valid' do + freeze_time do + encoded_message = JWT.encode(payload.merge(iat: (Time.current - 10.seconds).to_i), test_class.secret, 'HS256') + payload = test_class.decode_jwt(encoded_message, iat_after: Time.current - 20.seconds) + + expect(payload[0]).to match a_hash_including('iat' => be_a(Integer)) + end + end + + it 'raises an error if iat is invalid' do + encoded_message = JWT.encode(payload.merge(iat: 'wrong'), test_class.secret, 'HS256') - expect { test_class.decode_jwt_for_issuer('test_issuer', encoded_message) }.to raise_error(JWT::DecodeError) + expect { test_class.decode_jwt(encoded_message, iat_after: true) }.to raise_error(JWT::DecodeError) + end + + it 'raises an error if iat is absent' do + encoded_message = JWT.encode(payload, test_class.secret, 'HS256') + + expect { test_class.decode_jwt(encoded_message, iat_after: true) }.to raise_error(JWT::DecodeError) + end + + it 'raises an error if iat is too far in the past' do + freeze_time do + encoded_message = JWT.encode(payload.merge(iat: (Time.current - 30.seconds).to_i), test_class.secret, 'HS256') + expect do + test_class.decode_jwt(encoded_message, iat_after: Time.current - 20.seconds) + end.to raise_error(JWT::ExpiredSignature, 'Token has expired') + end + end end end end diff --git a/spec/lib/gitlab/mail_room/authenticator_spec.rb b/spec/lib/gitlab/mail_room/authenticator_spec.rb new file mode 100644 index 00000000000..44120902661 --- /dev/null +++ b/spec/lib/gitlab/mail_room/authenticator_spec.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::MailRoom::Authenticator do + let(:yml_config) do + { + enabled: true, + address: 'address@example.com' + } + end + + let(:incoming_email_secret_path) { '/path/to/incoming_email_secret' } + let(:incoming_email_config) { yml_config.merge(secret_file: incoming_email_secret_path) } + + let(:service_desk_email_secret_path) { '/path/to/service_desk_email_secret' } + let(:service_desk_email_config) { yml_config.merge(secret_file: service_desk_email_secret_path) } + + let(:configs) do + { + incoming_email: incoming_email_config, + service_desk_email: service_desk_email_config + } + end + + before do + allow(Gitlab::MailRoom).to receive(:enabled_configs).and_return(configs) + + described_class.clear_memoization(:jwt_secret_incoming_email) + described_class.clear_memoization(:jwt_secret_service_desk_email) + end + + after do + described_class.clear_memoization(:jwt_secret_incoming_email) + described_class.clear_memoization(:jwt_secret_service_desk_email) + end + + around do |example| + freeze_time do + example.run + end + end + + describe '#verify_api_request' do + let(:incoming_email_secret) { SecureRandom.hex(16) } + let(:service_desk_email_secret) { SecureRandom.hex(16) } + let(:payload) { { iss: described_class::INTERNAL_API_REQUEST_JWT_ISSUER, iat: (Time.current - 5.minutes + 1.second).to_i } } + + before do + allow(described_class).to receive(:secret).with(:incoming_email).and_return(incoming_email_secret) + allow(described_class).to receive(:secret).with(:service_desk_email).and_return(service_desk_email_secret) + end + + context 'verify a valid token' do + it 'returns the decoded payload' do + encoded_token = JWT.encode(payload, incoming_email_secret, 'HS256') + headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token } + + expect(described_class.verify_api_request(headers, 'incoming_email')[0]).to match a_hash_including( + "iss" => "gitlab-mailroom", + "iat" => be_a(Integer) + ) + + encoded_token = JWT.encode(payload, service_desk_email_secret, 'HS256') + headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token } + + expect(described_class.verify_api_request(headers, 'service_desk_email')[0]).to match a_hash_including( + "iss" => "gitlab-mailroom", + "iat" => be_a(Integer) + ) + end + end + + context 'verify an invalid token' do + it 'returns false' do + encoded_token = JWT.encode(payload, 'wrong secret', 'HS256') + headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token } + + expect(described_class.verify_api_request(headers, 'incoming_email')).to eq(false) + end + end + + context 'verify a valid token but wrong mailbox type' do + it 'returns false' do + encoded_token = JWT.encode(payload, incoming_email_secret, 'HS256') + headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token } + + expect(described_class.verify_api_request(headers, 'service_desk_email')).to eq(false) + end + end + + context 'verify a valid token but wrong issuer' do + let(:payload) { { iss: 'invalid_issuer' } } + + it 'returns false' do + encoded_token = JWT.encode(payload, incoming_email_secret, 'HS256') + headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token } + + expect(described_class.verify_api_request(headers, 'incoming_email')).to eq(false) + end + end + + context 'verify a valid token but expired' do + let(:payload) { { iss: described_class::INTERNAL_API_REQUEST_JWT_ISSUER, iat: (Time.current - 5.minutes - 1.second).to_i } } + + it 'returns false' do + encoded_token = JWT.encode(payload, incoming_email_secret, 'HS256') + headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token } + + expect(described_class.verify_api_request(headers, 'incoming_email')).to eq(false) + end + end + + context 'verify a valid token but wrong header field' do + it 'returns false' do + encoded_token = JWT.encode(payload, incoming_email_secret, 'HS256') + headers = { 'a-wrong-header' => encoded_token } + + expect(described_class.verify_api_request(headers, 'incoming_email')).to eq(false) + end + end + + context 'verify headers for a disabled mailbox type' do + let(:configs) { { service_desk_email: service_desk_email_config } } + + it 'returns false' do + encoded_token = JWT.encode(payload, incoming_email_secret, 'HS256') + headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token } + + expect(described_class.verify_api_request(headers, 'incoming_email')).to eq(false) + end + end + + context 'verify headers for a non-existing mailbox type' do + it 'returns false' do + headers = { described_class::INTERNAL_API_REQUEST_HEADER => 'something' } + + expect(described_class.verify_api_request(headers, 'invalid_mailbox_type')).to eq(false) + end + end + end + + describe '#secret' do + let(:incoming_email_secret) { SecureRandom.hex(16) } + let(:service_desk_email_secret) { SecureRandom.hex(16) } + + context 'the secret is valid' do + before do + allow(described_class).to receive(:read_secret).with(incoming_email_secret_path).and_return(incoming_email_secret).once + allow(described_class).to receive(:read_secret).with(service_desk_email_secret_path).and_return(service_desk_email_secret).once + end + + it 'returns the memorized secret from a file' do + expect(described_class.secret(:incoming_email)).to eql(incoming_email_secret) + # The second call does not trigger secret read again + expect(described_class.secret(:incoming_email)).to eql(incoming_email_secret) + expect(described_class).to have_received(:read_secret).with(incoming_email_secret_path).once + + expect(described_class.secret(:service_desk_email)).to eql(service_desk_email_secret) + # The second call does not trigger secret read again + expect(described_class.secret(:service_desk_email)).to eql(service_desk_email_secret) + expect(described_class).to have_received(:read_secret).with(service_desk_email_secret_path).once + end + end + + context 'the secret file is not configured' do + let(:incoming_email_config) { yml_config } + + it 'raises a SecretConfigurationError exception' do + expect do + described_class.secret(:incoming_email) + end.to raise_error(described_class::SecretConfigurationError, "incoming_email's secret_file configuration is missing") + end + end + + context 'the secret file not found' do + before do + allow(described_class).to receive(:read_secret).with(incoming_email_secret_path).and_raise(Errno::ENOENT) + end + + it 'raises a SecretConfigurationError exception' do + expect do + described_class.secret(:incoming_email) + end.to raise_error(described_class::SecretConfigurationError, "Fail to read incoming_email's secret: No such file or directory") + end + end + end +end diff --git a/spec/lib/gitlab/mail_room/mail_room_spec.rb b/spec/lib/gitlab/mail_room/mail_room_spec.rb index 0bd1a27c65e..a4fcf71a012 100644 --- a/spec/lib/gitlab/mail_room/mail_room_spec.rb +++ b/spec/lib/gitlab/mail_room/mail_room_spec.rb @@ -30,6 +30,7 @@ RSpec.describe Gitlab::MailRoom do end before do + allow(described_class).to receive(:load_yaml).and_return(configs) described_class.instance_variable_set(:@enabled_configs, nil) end @@ -38,10 +39,6 @@ RSpec.describe Gitlab::MailRoom do end describe '#enabled_configs' do - before do - allow(described_class).to receive(:load_yaml).and_return(configs) - end - context 'when both email and address is set' do it 'returns email configs' do expect(described_class.enabled_configs.size).to eq(2) @@ -79,7 +76,7 @@ RSpec.describe Gitlab::MailRoom do let(:custom_config) { { enabled: true, address: 'address@example.com' } } it 'overwrites missing values with the default' do - expect(described_class.enabled_configs.first[:port]).to eq(Gitlab::MailRoom::DEFAULT_CONFIG[:port]) + expect(described_class.enabled_configs.each_value.first[:port]).to eq(Gitlab::MailRoom::DEFAULT_CONFIG[:port]) end end @@ -88,7 +85,7 @@ RSpec.describe Gitlab::MailRoom do it 'returns only encoming_email' do expect(described_class.enabled_configs.size).to eq(1) - expect(described_class.enabled_configs.first[:worker]).to eq('EmailReceiverWorker') + expect(described_class.enabled_configs.each_value.first[:worker]).to eq('EmailReceiverWorker') end end @@ -100,11 +97,12 @@ RSpec.describe Gitlab::MailRoom do end it 'sets redis config' do - config = described_class.enabled_configs.first - - expect(config[:redis_url]).to eq('localhost') - expect(config[:redis_db]).to eq(99) - expect(config[:sentinels]).to eq('yes, them') + config = described_class.enabled_configs.each_value.first + expect(config).to include( + redis_url: 'localhost', + redis_db: 99, + sentinels: 'yes, them' + ) end end @@ -113,7 +111,7 @@ RSpec.describe Gitlab::MailRoom do let(:custom_config) { { log_path: 'tiny_log.log' } } it 'expands the log path to an absolute value' do - new_path = Pathname.new(described_class.enabled_configs.first[:log_path]) + new_path = Pathname.new(described_class.enabled_configs.each_value.first[:log_path]) expect(new_path.absolute?).to be_truthy end end @@ -122,9 +120,48 @@ RSpec.describe Gitlab::MailRoom do let(:custom_config) { { log_path: '/dev/null' } } it 'leaves the path as-is' do - expect(described_class.enabled_configs.first[:log_path]).to eq '/dev/null' + expect(described_class.enabled_configs.each_value.first[:log_path]).to eq '/dev/null' end end end end + + describe '#enabled_mailbox_types' do + context 'when all mailbox types are enabled' do + it 'returns the mailbox types' do + expect(described_class.enabled_mailbox_types).to match(%w[incoming_email service_desk_email]) + end + end + + context 'when an mailbox_types is disabled' do + let(:incoming_email_config) { yml_config.merge(enabled: false) } + + it 'returns the mailbox types' do + expect(described_class.enabled_mailbox_types).to match(%w[service_desk_email]) + end + end + + context 'when email is disabled' do + let(:custom_config) { { enabled: false } } + + it 'returns an empty array' do + expect(described_class.enabled_mailbox_types).to match_array([]) + end + end + end + + describe '#worker_for' do + context 'matched mailbox types' do + it 'returns the constantized worker class' do + expect(described_class.worker_for('incoming_email')).to eql(EmailReceiverWorker) + expect(described_class.worker_for('service_desk_email')).to eql(ServiceDeskEmailReceiverWorker) + end + end + + context 'non-existing mailbox_type' do + it 'returns nil' do + expect(described_class.worker_for('another_mailbox_type')).to be(nil) + end + end + end end diff --git a/spec/migrations/20211126115449_encrypt_static_objects_external_storage_auth_token_spec.rb b/spec/migrations/20211126115449_encrypt_static_objects_external_storage_auth_token_spec.rb index bc8b7c56676..bf4094eaa49 100644 --- a/spec/migrations/20211126115449_encrypt_static_objects_external_storage_auth_token_spec.rb +++ b/spec/migrations/20211126115449_encrypt_static_objects_external_storage_auth_token_spec.rb @@ -53,4 +53,26 @@ RSpec.describe EncryptStaticObjectsExternalStorageAuthToken, :migration do end end end + + context 'when static_objects_external_storage_auth_token is empty string' do + it 'does not break' do + settings = application_settings.create! + settings.update_column(:static_objects_external_storage_auth_token, '') + + reversible_migration do |migration| + migration.before -> { + settings = application_settings.first + + expect(settings.static_objects_external_storage_auth_token).to eq('') + expect(settings.static_objects_external_storage_auth_token_encrypted).to be_nil + } + migration.after -> { + settings = application_settings.first + + expect(settings.static_objects_external_storage_auth_token).to eq('') + expect(settings.static_objects_external_storage_auth_token_encrypted).to be_nil + } + end + end + end end diff --git a/spec/requests/api/internal/mail_room_spec.rb b/spec/requests/api/internal/mail_room_spec.rb new file mode 100644 index 00000000000..51abe92a125 --- /dev/null +++ b/spec/requests/api/internal/mail_room_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Internal::MailRoom do + let(:base_configs) do + { + enabled: true, + address: 'address@example.com', + port: 143, + ssl: false, + start_tls: false, + mailbox: 'inbox', + idle_timeout: 60, + log_path: Rails.root.join('log', 'mail_room_json.log').to_s, + expunge_deleted: false + } + end + + let(:enabled_configs) do + { + incoming_email: base_configs.merge( + secure_file: Rails.root.join('tmp', 'tests', '.incoming_email_secret').to_s + ), + service_desk_email: base_configs.merge( + secure_file: Rails.root.join('tmp', 'tests', '.service_desk_email').to_s + ) + } + end + + let(:auth_payload) { { 'iss' => Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_JWT_ISSUER, 'iat' => (Time.now - 10.seconds).to_i } } + + let(:incoming_email_secret) { 'incoming_email_secret' } + let(:service_desk_email_secret) { 'service_desk_email_secret' } + + let(:email_content) { fixture_file("emails/commands_in_reply.eml") } + + before do + allow(Gitlab::MailRoom::Authenticator).to receive(:secret).with(:incoming_email).and_return(incoming_email_secret) + allow(Gitlab::MailRoom::Authenticator).to receive(:secret).with(:service_desk_email).and_return(service_desk_email_secret) + allow(Gitlab::MailRoom).to receive(:enabled_configs).and_return(enabled_configs) + end + + around do |example| + freeze_time do + example.run + end + end + + describe "POST /internal/mail_room/*mailbox_type" do + context 'handle incoming_email successfully' do + let(:auth_headers) do + jwt_token = JWT.encode(auth_payload, incoming_email_secret, 'HS256') + { Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token } + end + + it 'schedules a EmailReceiverWorker job with raw email content' do + Sidekiq::Testing.fake! do + expect do + post api("/internal/mail_room/incoming_email"), headers: auth_headers, params: email_content + end.to change { EmailReceiverWorker.jobs.size }.by(1) + end + + expect(response).to have_gitlab_http_status(:ok) + + job = EmailReceiverWorker.jobs.last + expect(job).to match a_hash_including('args' => [email_content]) + end + end + + context 'handle service_desk_email successfully' do + let(:auth_headers) do + jwt_token = JWT.encode(auth_payload, service_desk_email_secret, 'HS256') + { Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token } + end + + it 'schedules a ServiceDeskEmailReceiverWorker job with raw email content' do + Sidekiq::Testing.fake! do + expect do + post api("/internal/mail_room/service_desk_email"), headers: auth_headers, params: email_content + end.to change { ServiceDeskEmailReceiverWorker.jobs.size }.by(1) + end + + expect(response).to have_gitlab_http_status(:ok) + + job = ServiceDeskEmailReceiverWorker.jobs.last + expect(job).to match a_hash_including('args' => [email_content]) + end + end + + context 'email content exceeds limit' do + let(:auth_headers) do + jwt_token = JWT.encode(auth_payload, incoming_email_secret, 'HS256') + { Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token } + end + + before do + allow(EmailReceiverWorker).to receive(:perform_async).and_raise( + Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError.new(EmailReceiverWorker, email_content.bytesize, email_content.bytesize - 1) + ) + end + + it 'responds with 400 bad request' do + Sidekiq::Testing.fake! do + expect do + post api("/internal/mail_room/incoming_email"), headers: auth_headers, params: email_content + end.not_to change { EmailReceiverWorker.jobs.size } + end + + expect(response).to have_gitlab_http_status(:bad_request) + expect(Gitlab::Json.parse(response.body)).to match a_hash_including( + { "success" => false, "message" => "EmailReceiverWorker job exceeds payload size limit" } + ) + end + end + + context 'not authenticated' do + it 'responds with 401 Unauthorized' do + post api("/internal/mail_room/incoming_email") + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'wrong token authentication' do + let(:auth_headers) do + jwt_token = JWT.encode(auth_payload, 'wrongsecret', 'HS256') + { Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token } + end + + it 'responds with 401 Unauthorized' do + post api("/internal/mail_room/incoming_email"), headers: auth_headers + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'wrong mailbox type authentication' do + let(:auth_headers) do + jwt_token = JWT.encode(auth_payload, service_desk_email_secret, 'HS256') + { Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token } + end + + it 'responds with 401 Unauthorized' do + post api("/internal/mail_room/incoming_email"), headers: auth_headers + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'not supported mailbox type' do + let(:auth_headers) do + jwt_token = JWT.encode(auth_payload, incoming_email_secret, 'HS256') + { Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token } + end + + it 'responds with 401 Unauthorized' do + post api("/internal/mail_room/invalid_mailbox_type"), headers: auth_headers + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'not enabled mailbox type' do + let(:enabled_configs) do + { + incoming_email: base_configs.merge( + secure_file: Rails.root.join('tmp', 'tests', '.incoming_email_secret').to_s + ) + } + end + + let(:auth_headers) do + jwt_token = JWT.encode(auth_payload, service_desk_email_secret, 'HS256') + { Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token } + end + + it 'responds with 401 Unauthorized' do + post api("/internal/mail_room/service_desk_email"), headers: auth_headers + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + end +end |