Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-01-06 09:10:35 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-01-06 09:10:35 +0300
commit25ceb3dc1c387950d777b71aabde00849d4c7bf9 (patch)
tree755188dc8d772ad10fb52f6eaa75a499ee15325a
parent0fbe2f816ecef98003377154b479d350f13597d7 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/stylesheets/framework/diffs.scss2
-rw-r--r--config/gitlab.yml.example4
-rw-r--r--config/mail_room.yml2
-rw-r--r--db/migrate/20211126115449_encrypt_static_objects_external_storage_auth_token.rb2
-rw-r--r--doc/api/deployments.md35
-rw-r--r--doc/ci/yaml/script.md4
-rw-r--r--lib/api/api.rb1
-rw-r--r--lib/api/deployments.rb2
-rw-r--r--lib/api/internal/mail_room.rb47
-rw-r--r--lib/gitlab/jwt_authenticatable.rb36
-rw-r--r--lib/gitlab/kas.rb2
-rw-r--r--lib/gitlab/mail_room.rb16
-rw-r--r--lib/gitlab/mail_room/authenticator.rb50
-rw-r--r--lib/gitlab/middleware/multipart.rb4
-rw-r--r--lib/gitlab/pages.rb2
-rw-r--r--lib/gitlab/workhorse.rb6
-rw-r--r--spec/config/mail_room_spec.rb3
-rw-r--r--spec/lib/gitlab/jwt_authenticatable_spec.rb163
-rw-r--r--spec/lib/gitlab/mail_room/authenticator_spec.rb188
-rw-r--r--spec/lib/gitlab/mail_room/mail_room_spec.rb63
-rw-r--r--spec/migrations/20211126115449_encrypt_static_objects_external_storage_auth_token_spec.rb22
-rw-r--r--spec/requests/api/internal/mail_room_spec.rb185
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