Commit 456f617c authored by Quang-Minh Nguyen's avatar Quang-Minh Nguyen Committed by Matthias Käppler

Add multi-secret and iat support to JwtAuthenticatable

Recently, JwtAuthenticatable supports multi-secret secret per inherited
class. This use case is not suitable for mailroom JWT flow, which
includes one secret per mailbox. JwtAuthenticatable hasn't supported iat
verification to control token age.
parent 264257aa
...@@ -228,6 +228,10 @@ production: &base ...@@ -228,6 +228,10 @@ production: &base
# client_id: "YOUR-CLIENT-ID" # client_id: "YOUR-CLIENT-ID"
# client_secret: "YOUR-CLIENT-SECRET" # 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 ## Consolidated object store config
## This will only take effect if the object_store sections are not defined ## This will only take effect if the object_store sections are not defined
## within the types (e.g. artifacts, lfs, etc.). ## within the types (e.g. artifacts, lfs, etc.).
......
:mailboxes: :mailboxes:
<% <%
require_relative "../lib/gitlab/mail_room" unless defined?(Gitlab::MailRoom) 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 %> :host: <%= config[:host].to_json %>
......
...@@ -299,6 +299,7 @@ module API ...@@ -299,6 +299,7 @@ module API
mount ::API::Internal::Lfs mount ::API::Internal::Lfs
mount ::API::Internal::Pages mount ::API::Internal::Pages
mount ::API::Internal::Kubernetes mount ::API::Internal::Kubernetes
mount ::API::Internal::MailRoom
version 'v3', using: :path do version 'v3', using: :path do
# Although the following endpoints are kept behind V3 namespace, # Although the following endpoints are kept behind V3 namespace,
......
# 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
...@@ -13,26 +13,38 @@ module Gitlab ...@@ -13,26 +13,38 @@ module Gitlab
module ClassMethods module ClassMethods
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
def decode_jwt_for_issuer(issuer, encoded_message) def decode_jwt(encoded_message, jwt_secret = secret, issuer: nil, iat_after: nil)
JWT.decode( options = { algorithm: 'HS256' }
encoded_message, options = options.merge(iss: issuer, verify_iss: true) if issuer.present?
secret, options = options.merge(verify_iat: true) if iat_after.present?
true,
{ iss: issuer, verify_iss: true, algorithm: 'HS256' } 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 end
def secret def secret
strong_memoize(:secret) do strong_memoize(:secret) do
Base64.strict_decode64(File.read(secret_path).chomp).tap do |bytes| read_secret(secret_path)
raise "#{secret_path} does not contain #{SECRET_LENGTH} bytes" if bytes.length != SECRET_LENGTH end
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
end end
def write_secret def write_secret(path = secret_path)
bytes = SecureRandom.random_bytes(SECRET_LENGTH) 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.chmod(0600) # If the file already existed, the '0600' passed to 'open' above was a no-op.
f.write(Base64.strict_encode64(bytes)) f.write(Base64.strict_encode64(bytes))
end end
......
...@@ -11,7 +11,7 @@ module Gitlab ...@@ -11,7 +11,7 @@ module Gitlab
class << self class << self
def verify_api_request(request_headers) 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 rescue JWT::DecodeError
nil nil
end end
......
...@@ -25,7 +25,7 @@ module Gitlab ...@@ -25,7 +25,7 @@ module Gitlab
# Email specific configuration which is merged with configuration # Email specific configuration which is merged with configuration
# fetched from YML config file. # fetched from YML config file.
ADDRESS_SPECIFIC_CONFIG = { MAILBOX_SPECIFIC_CONFIGS = {
incoming_email: { incoming_email: {
queue: 'email_receiver', queue: 'email_receiver',
worker: 'EmailReceiverWorker' worker: 'EmailReceiverWorker'
...@@ -38,7 +38,15 @@ module Gitlab ...@@ -38,7 +38,15 @@ module Gitlab
class << self class << self
def enabled_configs 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 end
private private
...@@ -48,7 +56,7 @@ module Gitlab ...@@ -48,7 +56,7 @@ module Gitlab
end end
def configs def configs
ADDRESS_SPECIFIC_CONFIG.keys.map { |key| fetch_config(key) } MAILBOX_SPECIFIC_CONFIGS.to_h { |key, _value| [key, fetch_config(key)] }
end end
def fetch_config(config_key) def fetch_config(config_key)
...@@ -63,7 +71,7 @@ module Gitlab ...@@ -63,7 +71,7 @@ module Gitlab
def merged_configs(config_key) def merged_configs(config_key)
yml_config = load_yaml.fetch(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| DEFAULT_CONFIG.merge(specific_config, yml_config) do |_key, oldval, newval|
newval.nil? ? oldval : newval newval.nil? ? oldval : newval
end end
......
# 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
...@@ -116,7 +116,7 @@ module Gitlab ...@@ -116,7 +116,7 @@ module Gitlab
jwt_token = params[param_key] jwt_token = params[param_key]
raise "Empty JWT param: #{param_key}" if jwt_token.blank? 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) raise "Invalid JWT payload: not a Hash" unless payload.is_a?(Hash)
upload_params = payload.fetch(JWT_PARAM_FIXED_KEY, {}) upload_params = payload.fetch(JWT_PARAM_FIXED_KEY, {})
...@@ -172,7 +172,7 @@ module Gitlab ...@@ -172,7 +172,7 @@ module Gitlab
encoded_message = env.delete(RACK_ENV_KEY) encoded_message = env.delete(RACK_ENV_KEY)
return @app.call(env) if encoded_message.blank? 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 ::Gitlab::Middleware::Multipart::Handler.new(env, message).with_open_files do
@app.call(env) @app.call(env)
......
...@@ -10,7 +10,7 @@ module Gitlab ...@@ -10,7 +10,7 @@ module Gitlab
class << self class << self
def verify_api_request(request_headers) 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 rescue JWT::DecodeError
false false
end end
......
...@@ -203,11 +203,11 @@ module Gitlab ...@@ -203,11 +203,11 @@ module Gitlab
end end
def verify_api_request!(request_headers) 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 end
def decode_jwt(encoded_message) def decode_jwt_with_issuer(encoded_message)
decode_jwt_for_issuer('gitlab-workhorse', encoded_message) decode_jwt(encoded_message, issuer: 'gitlab-workhorse')
end end
def secret_path def secret_path
......
...@@ -18,8 +18,9 @@ RSpec.describe 'mail_room.yml' do ...@@ -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) result = Gitlab::Popen.popen_with_detail(%W(ruby -rerb -e #{cmd}), absolute_path('config'), vars)
output = result.stdout output = result.stdout
errors = result.stderr
status = result.status 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]) YAML.safe_load(output, permitted_classes: [Symbol])
end end
......
...@@ -14,17 +14,12 @@ RSpec.describe Gitlab::JwtAuthenticatable do ...@@ -14,17 +14,12 @@ RSpec.describe Gitlab::JwtAuthenticatable do
end end
before do before do
begin FileUtils.rm_f(test_class.secret_path)
File.delete(test_class.secret_path)
rescue Errno::ENOENT
end
test_class.write_secret test_class.write_secret
end end
describe '.secret' do shared_examples 'reading secret from the secret path' do
subject(:secret) { test_class.secret }
it 'returns 32 bytes' do it 'returns 32 bytes' do
expect(secret).to be_a(String) expect(secret).to be_a(String)
expect(secret.length).to eq(32) expect(secret.length).to eq(32)
...@@ -32,62 +27,170 @@ RSpec.describe Gitlab::JwtAuthenticatable do ...@@ -32,62 +27,170 @@ RSpec.describe Gitlab::JwtAuthenticatable do
end end
it 'accepts a trailing newline' do 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) expect(secret.length).to eq(32)
end end
it 'raises an exception if the secret file cannot be read' do 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) expect { secret }.to raise_exception(Errno::ENOENT)
end end
it 'raises an exception if the secret file contains the wrong number of bytes' do 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) expect { secret }.to raise_exception(RuntimeError)
end end
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 describe '.write_secret' do
it 'uses mode 0600' do context 'without an input' do
expect(File.stat(test_class.secret_path).mode & 0777).to eq(0600) 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 end
it 'writes base64 data' do context 'with an input' do
bytes = Base64.strict_decode64(File.read(test_class.secret_path)) 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
end end
describe '.decode_jwt_for_issuer' do describe '.decode_jwt' do |decode|
let(:payload) { { 'iss' => 'test_issuer' } } 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 it 'raises an error when the header is signed with the wrong secret' do
encoded_message = JWT.encode(payload, test_class.secret, 'HS256') 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 end
it 'raises an error when the JWT is not signed' do context 'use an input secret' do
encoded_message = JWT.encode(payload, nil, 'none') 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 end
it 'raises an error when the header is signed with the wrong secret' do context 'issuer option' do
encoded_message = JWT.encode(payload, 'wrongsecret', 'HS256') 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 end
it 'raises an error when the issuer is incorrect' do context 'iat_after option' do
payload['iss'] = 'somebody else' it 'returns decoded payload if iat is valid' do
encoded_message = JWT.encode(payload, test_class.secret, 'HS256') 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 end
end end
# 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
...@@ -30,6 +30,7 @@ RSpec.describe Gitlab::MailRoom do ...@@ -30,6 +30,7 @@ RSpec.describe Gitlab::MailRoom do
end end
before do before do
allow(described_class).to receive(:load_yaml).and_return(configs)
described_class.instance_variable_set(:@enabled_configs, nil) described_class.instance_variable_set(:@enabled_configs, nil)
end end
...@@ -38,10 +39,6 @@ RSpec.describe Gitlab::MailRoom do ...@@ -38,10 +39,6 @@ RSpec.describe Gitlab::MailRoom do
end end
describe '#enabled_configs' do 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 context 'when both email and address is set' do
it 'returns email configs' do it 'returns email configs' do
expect(described_class.enabled_configs.size).to eq(2) expect(described_class.enabled_configs.size).to eq(2)
...@@ -79,7 +76,7 @@ RSpec.describe Gitlab::MailRoom do ...@@ -79,7 +76,7 @@ RSpec.describe Gitlab::MailRoom do
let(:custom_config) { { enabled: true, address: 'address@example.com' } } let(:custom_config) { { enabled: true, address: 'address@example.com' } }
it 'overwrites missing values with the default' do 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
end end
...@@ -88,7 +85,7 @@ RSpec.describe Gitlab::MailRoom do ...@@ -88,7 +85,7 @@ RSpec.describe Gitlab::MailRoom do
it 'returns only encoming_email' do it 'returns only encoming_email' do
expect(described_class.enabled_configs.size).to eq(1) 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
end end
...@@ -100,11 +97,12 @@ RSpec.describe Gitlab::MailRoom do ...@@ -100,11 +97,12 @@ RSpec.describe Gitlab::MailRoom do
end end
it 'sets redis config' do it 'sets redis config' do
config = described_class.enabled_configs.first config = described_class.enabled_configs.each_value.first
expect(config).to include(
expect(config[:redis_url]).to eq('localhost') redis_url: 'localhost',
expect(config[:redis_db]).to eq(99) redis_db: 99,
expect(config[:sentinels]).to eq('yes, them') sentinels: 'yes, them'
)
end end
end end
...@@ -113,7 +111,7 @@ RSpec.describe Gitlab::MailRoom do ...@@ -113,7 +111,7 @@ RSpec.describe Gitlab::MailRoom do
let(:custom_config) { { log_path: 'tiny_log.log' } } let(:custom_config) { { log_path: 'tiny_log.log' } }
it 'expands the log path to an absolute value' do 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 expect(new_path.absolute?).to be_truthy
end end
end end
...@@ -122,9 +120,48 @@ RSpec.describe Gitlab::MailRoom do ...@@ -122,9 +120,48 @@ RSpec.describe Gitlab::MailRoom do
let(:custom_config) { { log_path: '/dev/null' } } let(:custom_config) { { log_path: '/dev/null' } }
it 'leaves the path as-is' do 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
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 end
# 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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment