Commit 9f3db08f authored by Dmitriy Zaporozhets's avatar Dmitriy Zaporozhets

Merge branch 'ce-to-ee-2018-04-02' into 'master'

CE upstream - 2018-04-02 21:40 UTC

See merge request gitlab-org/gitlab-ee!5203
parents f35c2bf6 daf69927
......@@ -321,7 +321,7 @@ end
group :development do
gem 'foreman', '~> 0.84.0'
gem 'brakeman', '~> 3.6.0', require: false
gem 'brakeman', '~> 4.2', require: false
gem 'letter_opener_web', '~> 1.3.0'
gem 'rblineprof', '~> 0.3.6', platform: :mri, require: false
......
......@@ -103,7 +103,7 @@ GEM
autoprefixer-rails (>= 5.2.1)
sass (>= 3.3.4)
bootstrap_form (2.7.0)
brakeman (3.6.1)
brakeman (4.2.1)
browser (2.2.0)
builder (3.2.3)
bullet (5.5.1)
......@@ -1042,7 +1042,7 @@ DEPENDENCIES
binding_of_caller (~> 0.7.2)
bootstrap-sass (~> 3.3.0)
bootstrap_form (~> 2.7.0)
brakeman (~> 3.6.0)
brakeman (~> 4.2)
browser (~> 2.2)
bullet (~> 5.5.0)
bundler-audit (~> 0.5.0)
......
<script>
import Flash from '../../../flash';
import editForm from './edit_form.vue';
import Icon from '../../../vue_shared/components/icon.vue';
import { __ } from '../../../locale';
import Flash from '../../../flash';
import editForm from './edit_form.vue';
import Icon from '../../../vue_shared/components/icon.vue';
import { __ } from '../../../locale';
import eventHub from '../../event_hub';
export default {
components: {
editForm,
Icon,
export default {
components: {
editForm,
Icon,
},
props: {
isConfidential: {
required: true,
type: Boolean,
},
props: {
isConfidential: {
required: true,
type: Boolean,
},
isEditable: {
required: true,
type: Boolean,
},
service: {
required: true,
type: Object,
},
isEditable: {
required: true,
type: Boolean,
},
data() {
return {
edit: false,
};
service: {
required: true,
type: Object,
},
computed: {
confidentialityIcon() {
return this.isConfidential ? 'eye-slash' : 'eye';
},
},
data() {
return {
edit: false,
};
},
computed: {
confidentialityIcon() {
return this.isConfidential ? 'eye-slash' : 'eye';
},
methods: {
toggleForm() {
this.edit = !this.edit;
},
updateConfidentialAttribute(confidential) {
this.service.update('issue', { confidential })
.then(() => location.reload())
.catch(() => {
Flash(__('Something went wrong trying to change the confidentiality of this issue'));
});
},
},
created() {
eventHub.$on('closeConfidentialityForm', this.toggleForm);
},
beforeDestroy() {
eventHub.$off('closeConfidentialityForm', this.toggleForm);
},
methods: {
toggleForm() {
this.edit = !this.edit;
},
};
updateConfidentialAttribute(confidential) {
this.service
.update('issue', { confidential })
.then(() => location.reload())
.catch(() => {
Flash(
__(
'Something went wrong trying to change the confidentiality of this issue',
),
);
});
},
},
};
</script>
<template>
<div class="block issuable-sidebar-item confidentiality">
<div class="sidebar-collapsed-icon">
<div
class="sidebar-collapsed-icon"
@click="toggleForm"
>
<icon
:name="confidentialityIcon"
:size="16"
aria-hidden="true"
/>
</div>
......@@ -71,7 +85,6 @@
<div class="value sidebar-item-value hide-collapsed">
<editForm
v-if="edit"
:toggle-form="toggleForm"
:is-confidential="isConfidential"
:update-confidential-attribute="updateConfidentialAttribute"
/>
......
<script>
import editFormButtons from './edit_form_buttons.vue';
import { s__ } from '../../../locale';
import editFormButtons from './edit_form_buttons.vue';
import { s__ } from '../../../locale';
export default {
components: {
editFormButtons,
export default {
components: {
editFormButtons,
},
props: {
isConfidential: {
required: true,
type: Boolean,
},
props: {
isConfidential: {
required: true,
type: Boolean,
},
toggleForm: {
required: true,
type: Function,
},
updateConfidentialAttribute: {
required: true,
type: Function,
},
updateConfidentialAttribute: {
required: true,
type: Function,
},
computed: {
confidentialityOnWarning() {
return s__('confidentiality|You are going to turn on the confidentiality. This means that only team members with <strong>at least Reporter access</strong> are able to see and leave comments on the issue.');
},
confidentialityOffWarning() {
return s__('confidentiality|You are going to turn off the confidentiality. This means <strong>everyone</strong> will be able to see and leave a comment on this issue.');
},
},
computed: {
confidentialityOnWarning() {
return s__(
'confidentiality|You are going to turn on the confidentiality. This means that only team members with <strong>at least Reporter access</strong> are able to see and leave comments on the issue.',
);
},
};
confidentialityOffWarning() {
return s__(
'confidentiality|You are going to turn off the confidentiality. This means <strong>everyone</strong> will be able to see and leave a comment on this issue.',
);
},
},
};
</script>
<template>
......@@ -45,7 +45,6 @@
</p>
<edit-form-buttons
:is-confidential="isConfidential"
:toggle-form="toggleForm"
:update-confidential-attribute="updateConfidentialAttribute"
/>
</div>
......
<script>
import $ from 'jquery';
import eventHub from '../../event_hub';
export default {
props: {
isConfidential: {
required: true,
type: Boolean,
},
toggleForm: {
required: true,
type: Function,
},
updateConfidentialAttribute: {
required: true,
type: Function,
......@@ -22,6 +21,16 @@ export default {
return !this.isConfidential;
},
},
methods: {
closeForm() {
eventHub.$emit('closeConfidentialityForm');
$(this.$el).trigger('hidden.gl.dropdown');
},
submitForm() {
this.closeForm();
this.updateConfidentialAttribute(this.updateConfidentialBool);
},
},
};
</script>
......@@ -30,14 +39,14 @@ export default {
<button
type="button"
class="btn btn-default append-right-10"
@click="toggleForm"
@click="closeForm"
>
{{ __('Cancel') }}
</button>
<button
type="button"
class="btn btn-close"
@click.prevent="updateConfidentialAttribute(updateConfidentialBool)"
@click.prevent="submitForm"
>
{{ toggleButtonText }}
</button>
......
<script>
import editFormButtons from './edit_form_buttons.vue';
import issuableMixin from '../../../vue_shared/mixins/issuable';
import { __, sprintf } from '../../../locale';
import editFormButtons from './edit_form_buttons.vue';
import issuableMixin from '../../../vue_shared/mixins/issuable';
import { __, sprintf } from '../../../locale';
export default {
components: {
editFormButtons,
export default {
components: {
editFormButtons,
},
mixins: [issuableMixin],
props: {
isLocked: {
required: true,
type: Boolean,
},
mixins: [
issuableMixin,
],
props: {
isLocked: {
required: true,
type: Boolean,
},
toggleForm: {
required: true,
type: Function,
},
updateLockedAttribute: {
required: true,
type: Function,
},
updateLockedAttribute: {
required: true,
type: Function,
},
},
computed: {
lockWarning() {
return sprintf(
__(
'Lock this %{issuableDisplayName}? Only <strong>project members</strong> will be able to comment.',
),
{ issuableDisplayName: this.issuableDisplayName },
);
},
computed: {
lockWarning() {
return sprintf(__('Lock this %{issuableDisplayName}? Only <strong>project members</strong> will be able to comment.'), { issuableDisplayName: this.issuableDisplayName });
},
unlockWarning() {
return sprintf(__('Unlock this %{issuableDisplayName}? <strong>Everyone</strong> will be able to comment.'), { issuableDisplayName: this.issuableDisplayName });
},
unlockWarning() {
return sprintf(
__(
'Unlock this %{issuableDisplayName}? <strong>Everyone</strong> will be able to comment.',
),
{ issuableDisplayName: this.issuableDisplayName },
);
},
};
},
};
</script>
<template>
......@@ -54,7 +57,6 @@
<edit-form-buttons
:is-locked="isLocked"
:toggle-form="toggleForm"
:update-locked-attribute="updateLockedAttribute"
/>
</div>
......
<script>
import $ from 'jquery';
import eventHub from '../../event_hub';
export default {
props: {
isLocked: {
......@@ -6,11 +9,6 @@ export default {
type: Boolean,
},
toggleForm: {
required: true,
type: Function,
},
updateLockedAttribute: {
required: true,
type: Function,
......@@ -26,6 +24,17 @@ export default {
return !this.isLocked;
},
},
methods: {
closeForm() {
eventHub.$emit('closeLockForm');
$(this.$el).trigger('hidden.gl.dropdown');
},
submitForm() {
this.closeForm();
this.updateLockedAttribute(this.toggleLock);
},
},
};
</script>
......@@ -34,7 +43,7 @@ export default {
<button
type="button"
class="btn btn-default append-right-10"
@click="toggleForm"
@click="closeForm"
>
{{ __('Cancel') }}
</button>
......@@ -42,7 +51,7 @@ export default {
<button
type="button"
class="btn btn-close"
@click.prevent="updateLockedAttribute(toggleLock)"
@click.prevent="submitForm"
>
{{ buttonText }}
</button>
......
<script>
import Flash from '~/flash';
import editForm from './edit_form.vue';
import issuableMixin from '../../../vue_shared/mixins/issuable';
import Icon from '../../../vue_shared/components/icon.vue';
import Flash from '~/flash';
import editForm from './edit_form.vue';
import issuableMixin from '../../../vue_shared/mixins/issuable';
import Icon from '../../../vue_shared/components/icon.vue';
import eventHub from '../../event_hub';
export default {
components: {
editForm,
Icon,
},
mixins: [
issuableMixin,
],
export default {
components: {
editForm,
Icon,
},
mixins: [issuableMixin],
props: {
isLocked: {
required: true,
type: Boolean,
},
props: {
isLocked: {
required: true,
type: Boolean,
},
isEditable: {
required: true,
type: Boolean,
},
isEditable: {
required: true,
type: Boolean,
},
mediator: {
required: true,
type: Object,
validator(mediatorObject) {
return mediatorObject.service && mediatorObject.service.update && mediatorObject.store;
},
mediator: {
required: true,
type: Object,
validator(mediatorObject) {
return (
mediatorObject.service &&
mediatorObject.service.update &&
mediatorObject.store
);
},
},
},
computed: {
lockIcon() {
return this.isLocked ? 'lock' : 'lock-open';
},
computed: {
lockIcon() {
return this.isLocked ? 'lock' : 'lock-open';
},
isLockDialogOpen() {
return this.mediator.store.isLockDialogOpen;
},
isLockDialogOpen() {
return this.mediator.store.isLockDialogOpen;
},
},
methods: {
toggleForm() {
this.mediator.store.isLockDialogOpen = !this.mediator.store.isLockDialogOpen;
},
created() {
eventHub.$on('closeLockForm', this.toggleForm);
},
beforeDestroy() {
eventHub.$off('closeLockForm', this.toggleForm);
},
updateLockedAttribute(locked) {
this.mediator.service.update(this.issuableType, {
methods: {
toggleForm() {
this.mediator.store.isLockDialogOpen = !this.mediator.store
.isLockDialogOpen;
},
updateLockedAttribute(locked) {
this.mediator.service
.update(this.issuableType, {
discussion_locked: locked,
})
.then(() => location.reload())
.catch(() => Flash(this.__(`Something went wrong trying to change the locked state of this ${this.issuableDisplayName}`)));
},
.catch(() =>
Flash(
this.__(
`Something went wrong trying to change the locked state of this ${
this.issuableDisplayName
}`,
),
),
);
},
};
},
};
</script>
<template>
<div class="block issuable-sidebar-item lock">
<div class="sidebar-collapsed-icon">
<div
class="sidebar-collapsed-icon"
@click="toggleForm"
>
<icon
:name="lockIcon"
:size="16"
aria-hidden="true"
class="sidebar-item-icon is-active"
/>
......@@ -85,7 +108,6 @@
<div class="value sidebar-item-value hide-collapsed">
<edit-form
v-if="isLockDialogOpen"
:toggle-form="toggleForm"
:is-locked="isLocked"
:update-locked-attribute="updateLockedAttribute"
:issuable-type="issuableType"
......
......@@ -46,6 +46,8 @@ class Projects::ServicesController < Projects::ApplicationController
else
{ error: true, message: 'Validations failed.', service_response: @service.errors.full_messages.join(',') }
end
rescue Gitlab::HTTP::BlockedUrlError => e
{ error: true, message: 'Test failed.', service_response: e.message }
end
def success_message
......
......@@ -28,7 +28,11 @@ module Projects
def add_repository_to_project
if project.external_import? && !unknown_url?
raise Error, 'Blocked import URL.' if Gitlab::UrlBlocker.blocked_url?(project.import_url, valid_ports: Project::VALID_IMPORT_PORTS)
begin
Gitlab::UrlBlocker.validate!(project.import_url, valid_ports: Project::VALID_IMPORT_PORTS)
rescue Gitlab::UrlBlocker::BlockedUrlError => e
raise Error, "Blocked import URL: #{e.message}"
end
end
# We should skip the repository for a GitHub import or GitLab project import,
......
......@@ -4,8 +4,8 @@
# protect against Server-side Request Forgery (SSRF).
class ImportableUrlValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
if Gitlab::UrlBlocker.blocked_url?(value, valid_ports: Project::VALID_IMPORT_PORTS)
record.errors.add(attribute, "imports are not allowed from that URL")
end
Gitlab::UrlBlocker.validate!(value, valid_ports: Project::VALID_IMPORT_PORTS)
rescue Gitlab::UrlBlocker::BlockedUrlError => e
record.errors.add(attribute, "is blocked: #{e.message}")
end
end
---
title: Update brakeman 3.6.1 to 4.2.1
merge_request: 18122
author: Takuya Noguchi
type: other
......@@ -4,6 +4,8 @@
# calling internal IP or services.
module Gitlab
class HTTP
BlockedUrlError = Class.new(StandardError)
include HTTParty # rubocop:disable Gitlab/HTTParty
connection_adapter ProxyHTTPConnectionAdapter
......
......@@ -10,8 +10,12 @@
module Gitlab
class ProxyHTTPConnectionAdapter < HTTParty::ConnectionAdapter
def connection
if !allow_local_requests? && blocked_url?
raise URI::InvalidURIError
unless allow_local_requests?
begin
Gitlab::UrlBlocker.validate!(uri, allow_local_network: false)
rescue Gitlab::UrlBlocker::BlockedUrlError => e
raise Gitlab::HTTP::BlockedUrlError, "URL '#{uri}' is blocked: #{e.message}"
end
end
super
......@@ -19,10 +23,6 @@ module Gitlab
private
def blocked_url?
Gitlab::UrlBlocker.blocked_url?(uri, allow_private_networks: false)
end
def allow_local_requests?
options.fetch(:allow_local_requests, allow_settings_local_requests?)
end
......
......@@ -2,48 +2,84 @@ require 'resolv'
module Gitlab
class UrlBlocker
class << self
def blocked_url?(url, allow_private_networks: true, valid_ports: [])
return false if url.nil?
BlockedUrlError = Class.new(StandardError)
blocked_ips = ["127.0.0.1", "::1", "0.0.0.0"]
blocked_ips.concat(Socket.ip_address_list.map(&:ip_address))
class << self
def validate!(url, allow_localhost: false, allow_local_network: true, valid_ports: [])
return true if url.nil?
begin
uri = Addressable::URI.parse(url)
# Allow imports from the GitLab instance itself but only from the configured ports
return false if internal?(uri)
rescue Addressable::URI::InvalidURIError
raise BlockedUrlError, "URI is invalid"
end
return true if blocked_port?(uri.port, valid_ports)
return true if blocked_user_or_hostname?(uri.user)
return true if blocked_user_or_hostname?(uri.hostname)
# Allow imports from the GitLab instance itself but only from the configured ports
return true if internal?(uri)
addrs_info = Addrinfo.getaddrinfo(uri.hostname, 80, nil, :STREAM)
server_ips = addrs_info.map(&:ip_address)
port = uri.port || uri.default_port
validate_port!(port, valid_ports) if valid_ports.any?
validate_user!(uri.user)
validate_hostname!(uri.hostname)
return true if (blocked_ips & server_ips).any?
return true if !allow_private_networks && private_network?(addrs_info)
rescue Addressable::URI::InvalidURIError
return true
begin
addrs_info = Addrinfo.getaddrinfo(uri.hostname, port, nil, :STREAM)
rescue SocketError
return false
return true
end
validate_localhost!(addrs_info) unless allow_localhost
validate_local_network!(addrs_info) unless allow_local_network
true
end
def blocked_url?(*args)
validate!(*args)
false
rescue BlockedUrlError
true
end
private
def blocked_port?(port, valid_ports)
return false if port.blank? || valid_ports.blank?
def validate_port!(port, valid_ports)
return if port.blank?
# Only ports under 1024 are restricted
return if port >= 1024
return if valid_ports.include?(port)
port < 1024 && !valid_ports.include?(port)
raise BlockedUrlError, "Only allowed ports are #{valid_ports.join(', ')}, and any over 1024"
end
def blocked_user_or_hostname?(value)
return false if value.blank?
def validate_user!(value)
return if value.blank?
return if value =~ /\A\p{Alnum}/
value !~ /\A\p{Alnum}/
raise BlockedUrlError, "Username needs to start with an alphanumeric character"
end
def validate_hostname!(value)
return if value.blank?
return if value =~ /\A\p{Alnum}/
raise BlockedUrlError, "Hostname needs to start with an alphanumeric character"
end
def validate_localhost!(addrs_info)
local_ips = ["127.0.0.1", "::1", "0.0.0.0"]
local_ips.concat(Socket.ip_address_list.map(&:ip_address))
return if (local_ips & addrs_info.map(&:ip_address)).empty?
raise BlockedUrlError, "Requests to localhost are not allowed"
end
def validate_local_network!(addrs_info)
return unless addrs_info.any? { |addr| addr.ipv4_private? || addr.ipv6_sitelocal? }
raise BlockedUrlError, "Requests to the local network are not allowed"
end
def internal?(uri)
......@@ -60,10 +96,6 @@ module Gitlab
(uri.port.blank? || uri.port == config.gitlab_shell.ssh_port)
end
def private_network?(addrs_info)
addrs_info.any? { |addr| addr.ipv4_private? || addr.ipv6_sitelocal? }
end
def config
Gitlab.config
end
......
......@@ -14,7 +14,7 @@ describe 'Discussion Lock', :js do
project.add_developer(user)
end
context 'when the discussion is unlocked' do
context 'when the discussion is unlocked' do
it 'the user can lock the issue' do
visit project_issue_path(project, issue)
......
......@@ -161,6 +161,50 @@ feature 'Issue Sidebar' do
end
end
end
context 'interacting with collapsed sidebar', :js do
collapsed_sidebar_selector = 'aside.right-sidebar.right-sidebar-collapsed'
expanded_sidebar_selector = 'aside.right-sidebar.right-sidebar-expanded'
confidentiality_sidebar_block = '.block.confidentiality'
lock_sidebar_block = '.block.lock'
collapsed_sidebar_block_icon = '.sidebar-collapsed-icon'
before do
resize_screen_sm
end
it 'confidentiality block expands then collapses sidebar' do
expect(page).to have_css(collapsed_sidebar_selector)
page.within(confidentiality_sidebar_block) do
find(collapsed_sidebar_block_icon).click
end
expect(page).to have_css(expanded_sidebar_selector)
page.within(confidentiality_sidebar_block) do
page.find('button', text: 'Cancel').click
end
expect(page).to have_css(collapsed_sidebar_selector)
end
it 'lock block expands then collapses sidebar' do
expect(page).to have_css(collapsed_sidebar_selector)
page.within(lock_sidebar_block) do
find(collapsed_sidebar_block_icon).click
end
expect(page).to have_css(expanded_sidebar_selector)
page.within(lock_sidebar_block) do
page.find('button', text: 'Cancel').click
end
expect(page).to have_css(collapsed_sidebar_selector)
end
end
end
context 'as a guest' do
......
......@@ -62,4 +62,22 @@ describe('Confidential Issue Sidebar Block', () => {
done();
});
});
it('displays the edit form when opened from collapsed state', (done) => {
expect(vm1.edit).toBe(false);
vm1.$el.querySelector('.sidebar-collapsed-icon').click();
expect(vm1.edit).toBe(true);
setTimeout(() => {
expect(
vm1.$el
.innerHTML
.includes('You are going to turn off the confidentiality.'),
).toBe(true);
done();
});
});
});
......@@ -68,4 +68,22 @@ describe('LockIssueSidebar', () => {
done();
});
});
it('displays the edit form when opened from collapsed state', (done) => {
expect(vm1.isLockDialogOpen).toBe(false);
vm1.$el.querySelector('.sidebar-collapsed-icon').click();
expect(vm1.isLockDialogOpen).toBe(true);
setTimeout(() => {
expect(
vm1.$el
.innerHTML
.includes('Unlock this issue?'),
).toBe(true);
done();
});
});
});
......@@ -12,11 +12,11 @@ describe Gitlab::HTTP do
end
it 'deny requests to localhost' do
expect { described_class.get('http://localhost:3003') }.to raise_error(URI::InvalidURIError)
expect { described_class.get('http://localhost:3003') }.to raise_error(Gitlab::HTTP::BlockedUrlError)
end
it 'deny requests to private network' do
expect { described_class.get('http://192.168.1.2:3003') }.to raise_error(URI::InvalidURIError)
expect { described_class.get('http://192.168.1.2:3003') }.to raise_error(Gitlab::HTTP::BlockedUrlError)
end
context 'if allow_local_requests set to true' do
......@@ -41,7 +41,7 @@ describe Gitlab::HTTP do
context 'if allow_local_requests set to false' do
it 'override the global value and ban requests to localhost or private network' do
expect { described_class.get('http://localhost:3003', allow_local_requests: false) }.to raise_error(URI::InvalidURIError)
expect { described_class.get('http://localhost:3003', allow_local_requests: false) }.to raise_error(Gitlab::HTTP::BlockedUrlError)
end
end
end
......
......@@ -74,13 +74,13 @@ describe Gitlab::UrlBlocker do
expect(described_class.blocked_url?('https://gitlab.com/foo/foo.git')).to be false
end
context 'when allow_private_networks is' do
let(:private_networks) { ['192.168.1.2', '10.0.0.2', '172.16.0.2'] }
context 'when allow_local_network is' do
let(:local_ips) { ['192.168.1.2', '10.0.0.2', '172.16.0.2'] }
let(:fake_domain) { 'www.fakedomain.fake' }
context 'true (default)' do
it 'does not block urls from private networks' do
private_networks.each do |ip|
local_ips.each do |ip|
stub_domain_resolv(fake_domain, ip)
expect(described_class).not_to be_blocked_url("http://#{fake_domain}")
......@@ -94,14 +94,14 @@ describe Gitlab::UrlBlocker do
context 'false' do
it 'blocks urls from private networks' do
private_networks.each do |ip|
local_ips.each do |ip|
stub_domain_resolv(fake_domain, ip)
expect(described_class).to be_blocked_url("http://#{fake_domain}", allow_private_networks: false)
expect(described_class).to be_blocked_url("http://#{fake_domain}", allow_local_network: false)
unstub_domain_resolv
expect(described_class).to be_blocked_url("http://#{ip}", allow_private_networks: false)
expect(described_class).to be_blocked_url("http://#{ip}", allow_local_network: false)
end
end
end
......
......@@ -251,14 +251,14 @@ describe Project do
project2 = build(:project, import_url: 'http://localhost:9000/t.git')
expect(project2).to be_invalid
expect(project2.errors[:import_url]).to include('imports are not allowed from that URL')
expect(project2.errors[:import_url].first).to include('Requests to localhost are not allowed')
end
it "does not allow blocked import_url port" do
project2 = build(:project, import_url: 'http://github.com:25/t.git')
expect(project2).to be_invalid
expect(project2.errors[:import_url]).to include('imports are not allowed from that URL')
expect(project2.errors[:import_url].first).to include('Only allowed ports are 22, 80, 443')
end
it 'creates mirror data when enabled' do
......
......@@ -156,7 +156,7 @@ describe Projects::ImportService do
result = described_class.new(project, user).execute
expect(result[:status]).to eq :error
expect(result[:message]).to end_with 'Blocked import URL.'
expect(result[:message]).to include('Requests to localhost are not allowed')
end
it 'fails with port 25' do
......@@ -165,7 +165,7 @@ describe Projects::ImportService do
result = described_class.new(project, user).execute
expect(result[:status]).to eq :error
expect(result[:message]).to end_with 'Blocked import URL.'
expect(result[:message]).to include('Only allowed ports are 22, 80, 443')
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