Commit 8d4ace6d authored by Illya Klymov's avatar Illya Klymov Committed by Fabio Pitino

Validate if an actual GIT repo is behind the URL when importing Repo by URL

parent 0335e0c4
import $ from 'jquery'; import $ from 'jquery';
import { debounce } from 'lodash';
import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates'; import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates';
import axios from '../lib/utils/axios_utils';
import { import {
convertToTitleCase, convertToTitleCase,
humanize, humanize,
...@@ -9,6 +11,23 @@ import { ...@@ -9,6 +11,23 @@ import {
let hasUserDefinedProjectPath = false; let hasUserDefinedProjectPath = false;
let hasUserDefinedProjectName = false; let hasUserDefinedProjectName = false;
const invalidInputClass = 'gl-field-error-outline';
const validateImportCredentials = (url, user, password) => {
const endpoint = `${gon.relative_url_root}/import/url/validate`;
return axios
.post(endpoint, {
url,
user,
password,
})
.then(({ data }) => data)
.catch(() => ({
// intentionally reporting success in case of validation error
// we do not want to block users from trying import in case of validation exception
success: true,
}));
};
const onProjectNameChange = ($projectNameInput, $projectPathInput) => { const onProjectNameChange = ($projectNameInput, $projectPathInput) => {
const slug = slugify(convertUnicodeToAscii($projectNameInput.val())); const slug = slugify(convertUnicodeToAscii($projectNameInput.val()));
...@@ -85,7 +104,10 @@ const bindHowToImport = () => { ...@@ -85,7 +104,10 @@ const bindHowToImport = () => {
const bindEvents = () => { const bindEvents = () => {
const $newProjectForm = $('#new_project'); const $newProjectForm = $('#new_project');
const $projectImportUrl = $('#project_import_url'); const $projectImportUrl = $('#project_import_url');
const $projectImportUrlWarning = $('.js-import-url-warning'); const $projectImportUrlUser = $('#project_import_url_user');
const $projectImportUrlPassword = $('#project_import_url_password');
const $projectImportUrlError = $('.js-import-url-error');
const $projectImportForm = $('.project-import form');
const $projectPath = $('.tab-pane.active #project_path'); const $projectPath = $('.tab-pane.active #project_path');
const $useTemplateBtn = $('.template-button > input'); const $useTemplateBtn = $('.template-button > input');
const $projectFieldsForm = $('.project-fields-form'); const $projectFieldsForm = $('.project-fields-form');
...@@ -139,12 +161,15 @@ const bindEvents = () => { ...@@ -139,12 +161,15 @@ const bindEvents = () => {
$projectPath.val($projectPath.val().trim()); $projectPath.val($projectPath.val().trim());
}); });
function updateUrlPathWarningVisibility() { const updateUrlPathWarningVisibility = debounce(async () => {
const url = $projectImportUrl.val(); const { success: isUrlValid } = await validateImportCredentials(
const URL_PATTERN = /(?:git|https?):\/\/.*\/.*\.git$/; $projectImportUrl.val(),
const isUrlValid = URL_PATTERN.test(url); $projectImportUrlUser.val(),
$projectImportUrlWarning.toggleClass('hide', isUrlValid); $projectImportUrlPassword.val(),
} );
$projectImportUrl.toggleClass(invalidInputClass, !isUrlValid);
$projectImportUrlError.toggleClass('hide', isUrlValid);
}, 500);
let isProjectImportUrlDirty = false; let isProjectImportUrlDirty = false;
$projectImportUrl.on('blur', () => { $projectImportUrl.on('blur', () => {
...@@ -153,9 +178,22 @@ const bindEvents = () => { ...@@ -153,9 +178,22 @@ const bindEvents = () => {
}); });
$projectImportUrl.on('keyup', () => { $projectImportUrl.on('keyup', () => {
deriveProjectPathFromUrl($projectImportUrl); deriveProjectPathFromUrl($projectImportUrl);
// defer error message till first input blur });
if (isProjectImportUrlDirty) {
updateUrlPathWarningVisibility(); [$projectImportUrl, $projectImportUrlUser, $projectImportUrlPassword].forEach(($f) => {
$f.on('input', () => {
if (isProjectImportUrlDirty) {
updateUrlPathWarningVisibility();
}
});
});
$projectImportForm.on('submit', (e) => {
const $invalidFields = $projectImportForm.find(`.${invalidInputClass}`);
if ($invalidFields.length > 0) {
$invalidFields[0].focus();
e.preventDefault();
e.stopPropagation();
} }
}); });
......
# frozen_string_literal: true
class Import::UrlController < ApplicationController
feature_category :importers
def validate
result = Import::ValidateRemoteGitEndpointService.new(validate_params).execute
if result.success?
render json: { success: true }
else
render json: { success: false, message: result.message }
end
end
private
def validate_params
params.permit(:user, :password, :url)
end
end
# frozen_string_literal: true
module Import
class ValidateRemoteGitEndpointService
# Validates if the remote endpoint is a valid GIT repository
# Only smart protocol is supported
# Validation rules are taken from https://git-scm.com/docs/http-protocol#_smart_clients
GIT_SERVICE_NAME = "git-upload-pack"
GIT_EXPECTED_FIRST_PACKET_LINE = "# service=#{GIT_SERVICE_NAME}"
GIT_BODY_MESSAGE_REGEXP = /^[0-9a-f]{4}#{GIT_EXPECTED_FIRST_PACKET_LINE}/.freeze
# https://github.com/git/git/blob/master/Documentation/technical/protocol-common.txt#L56-L59
GIT_PROTOCOL_PKT_LEN = 4
GIT_MINIMUM_RESPONSE_LENGTH = GIT_PROTOCOL_PKT_LEN + GIT_EXPECTED_FIRST_PACKET_LINE.length
EXPECTED_CONTENT_TYPE = "application/x-#{GIT_SERVICE_NAME}-advertisement"
def initialize(params)
@params = params
end
def execute
uri = Gitlab::Utils.parse_url(@params[:url])
return error("Invalid URL") unless uri
uri.fragment = nil
url = Gitlab::Utils.append_path(uri.to_s, "/info/refs?service=#{GIT_SERVICE_NAME}")
response_body = ''
result = nil
Gitlab::HTTP.try_get(url, stream_body: true, follow_redirects: false, basic_auth: auth) do |fragment|
response_body += fragment
next if response_body.length < GIT_MINIMUM_RESPONSE_LENGTH
result = if status_code_is_valid(fragment) && content_type_is_valid(fragment) && response_body_is_valid(response_body)
:success
else
:error
end
# We are interested only in the first chunks of the response
# So we're using stream_body: true and breaking when receive enough body
break
end
if result == :success
ServiceResponse.success
else
ServiceResponse.error(message: "#{uri} is not a valid HTTP Git repository")
end
end
private
def auth
unless @params[:user].to_s.blank?
{
username: @params[:user],
password: @params[:password]
}
end
end
def status_code_is_valid(fragment)
fragment.http_response.code == '200'
end
def content_type_is_valid(fragment)
fragment.http_response['content-type'] == EXPECTED_CONTENT_TYPE
end
def response_body_is_valid(response_body)
response_body.match?(GIT_BODY_MESSAGE_REGEXP)
end
end
end
...@@ -83,7 +83,7 @@ ...@@ -83,7 +83,7 @@
.js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') } .js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') }
= form_for @project, html: { class: 'new_project' } do |f| = form_for @project, html: { class: 'new_project gl-show-field-errors' } do |f|
%hr %hr
= render "shared/import_form", f: f = render "shared/import_form", f: f
= render 'projects/new_project_fields', f: f, project_name_id: "import-url-name", hide_init_with_readme: true, track_label: track_label = render 'projects/new_project_fields', f: f, project_name_id: "import-url-name", hide_init_with_readme: true, track_label: track_label
...@@ -9,17 +9,12 @@ ...@@ -9,17 +9,12 @@
= f.text_field :import_url, value: import_url.sanitized_url, = f.text_field :import_url, value: import_url.sanitized_url,
autocomplete: 'off', class: 'form-control gl-form-input', placeholder: 'https://gitlab.company.com/group/project.git', required: true autocomplete: 'off', class: 'form-control gl-form-input', placeholder: 'https://gitlab.company.com/group/project.git', required: true
= render 'shared/global_alert', = render 'shared/global_alert',
variant: :warning, variant: :danger,
alert_class: 'gl-mt-3 js-import-url-warning hide', alert_class: 'gl-mt-3 js-import-url-error hide',
dismissible: false, dismissible: false,
close_button_class: 'js-close-2fa-enabled-success-alert' do close_button_class: 'js-close-2fa-enabled-success-alert' do
.gl-alert-body .gl-alert-body
= s_('Import|A repository URL usually ends in a .git suffix, although this is not required. Double check to make sure your repository URL is correct.') = s_('Import|There is not a valid Git repository at this URL. If your HTTP repository is not publicly accessible, verify your credentials.')
.gl-alert.gl-alert-not-dismissible.gl-alert-warning.gl-mt-3.hide#project_import_url_warning
.gl-alert-container
= sprite_icon('warning-solid', css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title')
.gl-alert-content{ role: 'alert' }
.row .row
.form-group.col-md-6 .form-group.col-md-6
= f.label :import_url_user, class: 'label-bold' do = f.label :import_url_user, class: 'label-bold' do
......
...@@ -12,6 +12,10 @@ end ...@@ -12,6 +12,10 @@ end
namespace :import do namespace :import do
resources :available_namespaces, only: [:index], controller: :available_namespaces resources :available_namespaces, only: [:index], controller: :available_namespaces
namespace :url do
post :validate
end
resource :github, only: [:create, :new], controller: :github do resource :github, only: [:create, :new], controller: :github do
post :personal_access_token post :personal_access_token
get :status get :status
......
...@@ -69,6 +69,8 @@ RSpec.describe 'New project', :js do ...@@ -69,6 +69,8 @@ RSpec.describe 'New project', :js do
end end
it '"Import project" tab creates projects with features enabled' do it '"Import project" tab creates projects with features enabled' do
stub_request(:get, "http://foo.git/info/refs?service=git-upload-pack").to_return(status: 200, body: "001e# servdice=git-upload-pack")
visit new_project_path visit new_project_path
find('[data-qa-panel-name="import_project"]').click # rubocop:disable QA/SelectorUsage find('[data-qa-panel-name="import_project"]').click # rubocop:disable QA/SelectorUsage
...@@ -76,6 +78,9 @@ RSpec.describe 'New project', :js do ...@@ -76,6 +78,9 @@ RSpec.describe 'New project', :js do
first('.js-import-git-toggle-button').click first('.js-import-git-toggle-button').click
fill_in 'project_import_url', with: 'http://foo.git' fill_in 'project_import_url', with: 'http://foo.git'
wait_for_requests
fill_in 'project_name', with: 'import-project-with-features1' fill_in 'project_name', with: 'import-project-with-features1'
fill_in 'project_path', with: 'import-project-with-features1' fill_in 'project_path', with: 'import-project-with-features1'
choose 'project_visibility_level_20' choose 'project_visibility_level_20'
......
...@@ -17303,7 +17303,7 @@ msgstr[1] "" ...@@ -17303,7 +17303,7 @@ msgstr[1] ""
msgid "Importing..." msgid "Importing..."
msgstr "" msgstr ""
msgid "Import|A repository URL usually ends in a .git suffix, although this is not required. Double check to make sure your repository URL is correct." msgid "Import|There is not a valid Git repository at this URL. If your HTTP repository is not publicly accessible, verify your credentials."
msgstr "" msgstr ""
msgid "Improve customer support with Service Desk" msgid "Improve customer support with Service Desk"
......
...@@ -296,12 +296,16 @@ RSpec.describe 'New project', :js do ...@@ -296,12 +296,16 @@ RSpec.describe 'New project', :js do
expect(git_import_instructions).to have_content 'Git repository URL' expect(git_import_instructions).to have_content 'Git repository URL'
end end
it 'reports error if repo URL does not end with .git' do it 'reports error if repo URL is not a valid Git repository' do
stub_request(:get, "http://foo/bar/info/refs?service=git-upload-pack").to_return(status: 200, body: "not-a-git-repo")
fill_in 'project_import_url', with: 'http://foo/bar' fill_in 'project_import_url', with: 'http://foo/bar'
# simulate blur event # simulate blur event
find('body').click find('body').click
expect(page).to have_text('A repository URL usually ends in a .git suffix') wait_for_requests
expect(page).to have_text('There is not a valid Git repository at this URL')
end end
it 'keeps "Import project" tab open after form validation error' do it 'keeps "Import project" tab open after form validation error' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Import::UrlController do
let_it_be(:user) { create(:user) }
before do
login_as(user)
end
describe 'POST #validate' do
it 'reports success when service reports success status' do
allow_next_instance_of(Import::ValidateRemoteGitEndpointService) do |validate_endpoint_service|
allow(validate_endpoint_service).to receive(:execute).and_return(ServiceResponse.success)
end
post import_url_validate_path, params: { url: 'https://fake.repo' }
expect(json_response).to eq({ 'success' => true })
end
it 'exposes error message when service reports error' do
expect_next_instance_of(Import::ValidateRemoteGitEndpointService) do |validate_endpoint_service|
expect(validate_endpoint_service).to receive(:execute).and_return(ServiceResponse.error(message: 'foobar'))
end
post import_url_validate_path, params: { url: 'https://fake.repo' }
expect(json_response).to eq({ 'success' => false, 'message' => 'foobar' })
end
context 'with an anonymous user' do
before do
sign_out(user)
end
it 'redirects to sign-in page' do
post import_url_validate_path
expect(response).to redirect_to(new_user_session_path)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Import::ValidateRemoteGitEndpointService do
include StubRequests
let_it_be(:base_url) { 'http://demo.host/path' }
let_it_be(:endpoint_url) { "#{base_url}/info/refs?service=git-upload-pack" }
let_it_be(:error_message) { "#{base_url} is not a valid HTTP Git repository" }
describe '#execute' do
let(:valid_response) do
{ status: 200,
body: '001e# service=git-upload-pack',
headers: { 'Content-Type': 'application/x-git-upload-pack-advertisement' } }
end
it 'correctly handles URLs with fragment' do
allow(Gitlab::HTTP).to receive(:get)
described_class.new(url: "#{base_url}#somehash").execute
expect(Gitlab::HTTP).to have_received(:get).with(endpoint_url, basic_auth: nil, stream_body: true, follow_redirects: false)
end
context 'when receiving HTTP response' do
subject { described_class.new(url: base_url) }
it 'returns success when HTTP response is valid and contains correct payload' do
stub_full_request(endpoint_url, method: :get).to_return(valid_response)
result = subject.execute
expect(result).to be_a(ServiceResponse)
expect(result.success?).to be(true)
end
it 'reports error when status code is not 200' do
stub_full_request(endpoint_url, method: :get).to_return(valid_response.merge({ status: 301 }))
result = subject.execute
expect(result).to be_a(ServiceResponse)
expect(result.error?).to be(true)
expect(result.message).to eq(error_message)
end
it 'reports error when required header is missing' do
stub_full_request(endpoint_url, method: :get).to_return(valid_response.merge({ headers: nil }))
result = subject.execute
expect(result).to be_a(ServiceResponse)
expect(result.error?).to be(true)
expect(result.message).to eq(error_message)
end
it 'reports error when body is in invalid format' do
stub_full_request(endpoint_url, method: :get).to_return(valid_response.merge({ body: 'invalid content' }))
result = subject.execute
expect(result).to be_a(ServiceResponse)
expect(result.error?).to be(true)
expect(result.message).to eq(error_message)
end
it 'reports error when exception is raised' do
stub_full_request(endpoint_url, method: :get).to_raise(SocketError.new('dummy message'))
result = subject.execute
expect(result).to be_a(ServiceResponse)
expect(result.error?).to be(true)
expect(result.message).to eq(error_message)
end
end
it 'passes basic auth when credentials are provided' do
allow(Gitlab::HTTP).to receive(:get)
described_class.new(url: "#{base_url}#somehash", user: 'user', password: 'password').execute
expect(Gitlab::HTTP).to have_received(:get).with(endpoint_url, basic_auth: { username: 'user', password: 'password' }, stream_body: true, follow_redirects: false)
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