Project

General

Profile

Feature #29664 » 0001-Issue-Webhooks.patch

patch against current master - Jens Krämer, 2025-05-01 08:02

View differences:

Gemfile
36 36
gem "html-pipeline", "~> 2.13.2"
37 37
gem "sanitize", "~> 6.0"
38 38

  
39
# Triggering of Webhooks
40
gem "rest-client", "~> 2.1"
41

  
39 42
# Optional gem for LDAP authentication
40 43
group :ldap do
41 44
  gem 'net-ldap', '~> 0.17.0'
app/assets/images/icons.svg
501 501
      <path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"/>
502 502
      <path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6"/>
503 503
    </symbol>
504
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--webhook">
505
      <path d="M4.876 13.61a4 4 0 1 0 6.124 3.39h6"/>
506
      <path d="M15.066 20.502a4 4 0 1 0 1.934 -7.502c-.706 0 -1.424 .179 -2 .5l-3 -5.5"/>
507
      <path d="M16 8a4 4 0 1 0 -8 0c0 1.506 .77 2.818 2 3.5l-3 5.5"/>
508
    </symbol>
504 509
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--wiki-page">
505 510
      <path d="M6 4h11a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-11a1 1 0 0 1 -1 -1v-14a1 1 0 0 1 1 -1m3 0v18"/>
506 511
      <path d="M13 8l2 0"/>
app/controllers/webhooks_controller.rb
1
# frozen_string_literal: true
2

  
3
class WebhooksController < ApplicationController
4
  self.main_menu = false
5

  
6
  before_action :require_login
7
  before_action :find_webhook, only: [:edit, :update, :destroy]
8

  
9
  require_sudo_mode :create, :update, :destroy
10

  
11
  def index
12
    @webhooks = webhooks.order(:url)
13
  end
14

  
15
  def new
16
    @webhook = Webhook.new
17
  end
18

  
19
  def edit
20
  end
21

  
22
  def create
23
    @webhook = webhooks.build(webhook_params)
24
    if @webhook.save
25
      redirect_to webhooks_path
26
    else
27
      render :new
28
    end
29
  end
30

  
31
  def update
32
    if @webhook.update(webhook_params)
33
      redirect_to webhooks_path
34
    else
35
      render :edit
36
    end
37
  end
38

  
39
  def destroy
40
    @webhook.destroy
41
    redirect_to webhooks_path
42
  end
43

  
44
  private
45

  
46
  def webhook_params
47
    params.require(:webhook).permit(:url, :secret, :active, events: [], project_ids: [])
48
  end
49

  
50
  def find_webhook
51
    @webhook = webhooks.find(params[:id])
52
  rescue ActiveRecord::RecordNotFound
53
    render_404
54
  end
55

  
56
  def webhooks
57
    User.current.webhooks
58
  end
59
end
app/jobs/webhook_job.rb
1
# frozen_string_literal: true
2

  
3
class WebhookJob < ApplicationJob
4
  def perform(hook_id, payload_json)
5
    if hook = Webhook.find_by_id(hook_id)
6
      if hook.user&.active?
7
        User.current = hook.user
8
        hook.call payload_json
9
      else
10
        Rails.logger.debug { "WebhookJob: user with id=#{hook.user_id} is not active" }
11
      end
12
    else
13
      Rails.logger.debug { "WebhookJob: couldn't find hook with id=#{hook_id}" }
14
    end
15
  end
16
end
app/models/issue.rb
128 128
  after_create_commit :add_auto_watcher
129 129
  after_commit :create_parent_issue_journal
130 130

  
131
  after_create_commit  ->{ Webhook.trigger('issue.created', self) }
132
  after_update_commit  ->{ Webhook.trigger('issue.updated', self) }
133
  after_destroy_commit ->{ Webhook.trigger('issue.deleted', self) }
134

  
131 135
  # Returns a SQL conditions string used to find all issues visible by the specified user
132 136
  def self.visible_condition(user, options={})
133 137
    Project.allowed_to_condition(user, :view_issues, options) do |role, user|
app/models/project.rb
60 60
                          :class_name => 'IssueCustomField',
61 61
                          :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
62 62
                          :association_foreign_key => 'custom_field_id'
63
  has_and_belongs_to_many :webhooks
64

  
63 65
  # Default Custom Query
64 66
  belongs_to :default_issue_query, :class_name => 'IssueQuery'
65 67

  
app/models/user.rb
92 92
  has_one :atom_token, lambda {where "#{table.name}.action='feeds'"}, :class_name => 'Token'
93 93
  has_one :api_token, lambda {where "#{table.name}.action='api'"}, :class_name => 'Token'
94 94
  has_many :email_addresses, :dependent => :delete_all
95
  has_many :webhooks, dependent: :destroy
96

  
95 97
  belongs_to :auth_source
96 98

  
97 99
  scope :logged, lambda {where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}")}
app/models/webhook.rb
1
# frozen_string_literal: true
2

  
3
require 'rest-client'
4

  
5
class Webhook < ApplicationRecord
6
  Executor = Struct.new(:url, :payload, :secret) do
7
    # @return [RestClient::Response] if the POST request was successful
8
    # @raise [RestClient::Exception, Exception] a `RestClient::Exception` if an
9
    #   unexpected (i.e. non-successful) response status was set; it may contain
10
    #   the server response. For connection errors, we may raise any other
11
    #   exception.
12
    def call
13
      # DNS and therefore destination IPs might have changed since the record was saved, so check the URL, again.
14
      raise URI::BadURIError unless WebhookEndpointValidator.safe_webhook_uri?(url)
15

  
16
      headers = { accept: '*/*', content_type: :json, user_agent: 'Redmine' }
17
      if secret.present?
18
        headers['X-Redmine-Signature-256'] = compute_signature
19
      end
20
      Rails.logger.debug { "Webhook: POST #{url}" }
21
      RestClient.post url, payload, headers
22
    end
23

  
24
    # Computes the HMAC signature for the given payload and secret.
25
    # https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
26
    def compute_signature
27
      'sha256=' + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret, payload)
28
    end
29
  end
30

  
31
  belongs_to :user
32
  has_and_belongs_to_many :projects
33

  
34
  validates :url, presence: true, webhook_endpoint: true, length: { maximum: 2000 }
35
  validates :secret, length: { maximum: 255 }, allow_blank: true
36
  validate :check_events_array
37

  
38
  serialize :events, coder: YAML, type: Array
39

  
40
  scope :active, -> { where(active: true) }
41

  
42
  before_validation ->(hook){ hook.projects = hook.projects.to_a.select{|p| p.visible?(hook.user) } }
43

  
44
  # Triggers the given event for the given object, scheduling qualifying hooks
45
  # to be called.
46
  def self.trigger(event, object)
47
    hooks_for(event, object).each do |hook|
48
      payload = hook.payload(event, object)
49
      WebhookJob.perform_later(hook.id, payload.to_json)
50
    end
51
  end
52

  
53
  # Finds hooks for the given event and object.
54
  # Returns an array of hooks that are active, have the given event in their list
55
  # of events, and whose user can see the object.
56
  #
57
  # Object must have a project_id and respond to visible?(user)
58
  def self.hooks_for(event, object)
59
    Webhook.active
60
      .joins("INNER JOIN projects_webhooks on projects_webhooks.webhook_id = webhooks.id")
61
      .eager_load(:user)
62
      .where(users: { status: User::STATUS_ACTIVE }, projects_webhooks: { project_id: object.project_id })
63
      .to_a.select do |hook|
64
      hook.events.include?(event) && object.visible?(hook.user)
65
    end
66
  end
67

  
68
  def setable_projects
69
    Project.visible
70
  end
71

  
72
  def setable_events
73
    WebhookPayload::EVENTS
74
  end
75

  
76
  def setable_event_names
77
    setable_events.map{|type, actions| actions.map{|action| "#{type}.#{action}"}}.flatten
78
  end
79

  
80
  # computes the payload. this happens when the hook is triggered, and the
81
  # payload is stored as part of the hook job definition.
82
  # event must be of the form 'type.action' (like 'issue.created')
83
  def payload(event, object)
84
    WebhookPayload.new(event, object, user).to_h
85
  end
86

  
87
  # POSTs the given payload to the hook URL, returns true if successful, false otherwise.
88
  #
89
  # logs any unsuccessful hook calls, but does not raise
90
  def call(payload_json)
91
    Executor.new(url, payload_json, secret).call
92
    true
93
  rescue => e
94
    Rails.logger.warn { "Webhook Error: #{e.message} (#{e.class})\n#{e.backtrace.join "\n"}" }
95
    false
96
  end
97

  
98
  private
99

  
100
  def check_events_array
101
    unless events.is_a?(Array)
102
      errors.add(:events, :invalid)
103
      return
104
    end
105

  
106
    events.reject!(&:blank?)
107
    if (events - setable_event_names).any?
108
      errors.add(:events, :invalid)
109
    end
110
  end
111
end
app/models/webhook_payload.rb
1
# frozen_string_literal: true
2

  
3
# Webhook payload
4
class WebhookPayload
5
  attr_accessor :event, :object, :user
6

  
7
  def initialize(event, object, user)
8
    self.event = event
9
    self.object = object
10
    self.user = user
11
  end
12

  
13
  EVENTS = {
14
    issue: %w[created updated deleted]
15
  }
16

  
17
  def to_h
18
    type, action = event.split('.')
19
    if EVENTS[type.to_sym].include?(action)
20
      send("#{type}_payload", action)
21
    else
22
      raise ArgumentError, "invalid event: #{event}"
23
    end
24
  end
25

  
26
  private
27

  
28
  def issue_payload(action)
29
    issue = object
30
    if issue.current_journal.present?
31
      journal = issue.journals.visible(user).find_by_id(issue.current_journal.id)
32
    end
33
    ts = case action
34
         when 'created'
35
           issue.created_on
36
         when 'deleted'
37
           Time.now
38
         else
39
           journal&.created_on || issue.updated_on
40
         end
41
    h = {
42
      type: event,
43
      timestamp: ts.iso8601,
44
      data: {
45
        issue: ApiRenderer.new("app/views/issues/show.api.rsb", user).to_h(issue: issue)
46
      }
47
    }
48
    if action == 'updated' && journal.present?
49
      h[:data][:journal] = journal_payload(journal)
50
    end
51
    h
52
  end
53

  
54
  def journal_payload(journal)
55
    {
56
      id: journal.id,
57
      created_on: journal.created_on.iso8601,
58
      notes: journal.notes,
59
      user: {
60
        id: journal.user.id,
61
        name: journal.user.name,
62
      },
63
      details: journal.visible_details(user).map do |d|
64
        {
65
          property: d.property,
66
          prop_key: d.prop_key,
67
          old_value: d.old_value,
68
          value: d.value,
69
        }
70
      end
71
    }
72
  end
73

  
74
  # given a path to an API template (relative to RAILS_ROOT), renders it and returns the resulting hash
75
  class ApiRenderer
76
    include ApplicationHelper
77
    include CustomFieldsHelper
78
    attr_accessor :path, :params, :user
79

  
80
    DummyRequest = Struct.new(:params)
81

  
82
    def initialize(path, user, params = nil)
83
      self.path = path
84
      self.user = user
85
      self.params = params || {}
86
    end
87

  
88
    def to_h(**ivars)
89
      req = DummyRequest.new(params)
90
      api = Redmine::Views::Builders::Json.new(req, nil)
91
      ivars.each { |k, v| instance_variable_set :"@#{k}", v }
92
      original_user = User.current
93
      begin
94
        User.current = self.user
95
        instance_eval(File.read(Rails.root.join(path)), path, 1)
96
      ensure
97
        User.current = original_user
98
      end
99
    end
100
  end
101
end
app/views/my/account.html.erb
1 1
<div class="contextual">
2 2
<%= additional_emails_link(@user) %>
3 3
<%= link_to(sprite_icon('key', l(:button_change_password)), { :action => 'password'}, :class => 'icon icon-passwd') if @user.change_password_allowed? %>
4
<%= link_to sprite_icon('webhook', l(:label_webhook_plural)), webhooks_path, class: 'icon icon-webhook' %>
4 5
<%= call_hook(:view_my_account_contextual, :user => @user)%>
5 6
</div>
6 7

  
app/views/webhooks/_form.html.erb
1
<%= error_messages_for @webhook %>
2

  
3
<div class="splitcontent">
4
  <div class="splitcontentleft">
5
    <div class="box tabular">
6
      <p><%= f.text_field :url, required: true, size: 60 %>
7
        <em class="info"><%= l :webhook_url_info %></em>
8
      </p>
9
      <p>
10
        <%= f.text_field :secret %>
11
        <em class="info"><%= raw l :webhook_secret_info_html %></em>
12
      </p>
13
      <p><%= f.check_box :active %></p>
14
    </div>
15

  
16
    <h3><%= l :label_webhook_events %></h3>
17
    <div class="box tabular" id="events">
18
    <% @webhook.setable_events.keys.sort.each do |type| %>
19
      <fieldset id="<%= type %>_events"><legend><%= toggle_checkboxes_link("##{type}_events\ input") %><%= l_or_humanize(type, prefix: 'webhook_events_') %></legend>
20
        <% @webhook.setable_events[type].each do |action| %>
21
          <% name = "#{type}.#{action}" %>
22
          <label class="floating">
23
            <%= check_box_tag 'webhook[events][]', name, @webhook.events.include?(name), id: "webhook_events_#{name}" %>
24
            <%= l_or_humanize(name.tr('.', '_'), :prefix => 'webhook_events_') %>
25
          </label>
26
        <% end %>
27
        </fieldset>
28
    <% end %>
29
    <br /><%= check_all_links 'events' %>
30
    <%= hidden_field_tag 'webhook[events][]', '' %>
31
    </div>
32
  </div>
33

  
34
  <div class="splitcontentright">
35
    <fieldset class="box" id="webhook_project_ids"><legend><%= toggle_checkboxes_link("#webhook_project_ids input[type=checkbox]") %><%= l(:label_project_plural) %></legend>
36
      <% project_ids = @webhook.project_ids.to_a %>
37
      <%= render_project_nested_lists(@webhook.setable_projects) do |p|
38
        content_tag('label', check_box_tag('webhook[project_ids][]', p.id, project_ids.include?(p.id), :id => nil) + ' ' + h(p))
39
      end %>
40
      <%= hidden_field_tag('webhook[project_ids][]', '', :id => nil) %>
41
    </fieldset>
42

  
43
  </div>
44
</div>
app/views/webhooks/edit.html.erb
1
<h2><%= l :label_webhook_edit %></h2>
2

  
3
<%= labelled_form_for @webhook, html: { method: :patch } do |f| %>
4
  <%= render :partial => 'form', :locals => { :f => f } %>
5
  <%= submit_tag l(:button_save) %>
6
<% end %>
app/views/webhooks/index.html.erb
1
<div class="contextual">
2
  <%= link_to sprite_icon('add', l(:label_webhook_new)), new_webhook_path, class: 'icon icon-add' %>
3
</div>
4

  
5
<%= title l :label_webhook_plural %>
6

  
7
<% if @webhooks.any? %>
8
<div class="autoscroll">
9
<table class="list">
10
  <thead><tr>
11
    <th><%= l :field_active %></th>
12
    <th><%= l :label_url %></th>
13
    <th><%= l :label_webhook_events %></th>
14
    <th><%= l :label_project_plural %></th>
15
    <th></th>
16
  </tr></thead>
17
  <tbody>
18
  <% @webhooks.each do |webhook| %>
19
    <tr id="webhook_<%= webhook.id %>" class="<%= cycle("odd", "even") %>">
20
      <td><%= webhook.active ? l(:general_text_Yes) : l(:general_text_No) %></td>
21
      <td><%= truncate webhook.url, length: 40 %></td>
22
      <td><%= safe_join webhook.events.map{|e| content_tag :code, e }, ', ' %></td>
23
      <td><%= safe_join webhook.projects.visible.map{|p| link_to_project(p) }, ', ' %></td>
24
      <td class="buttons">
25
        <%= link_to sprite_icon('edit', l(:button_edit)), edit_webhook_path(webhook), class: 'icon icon-edit' %>
26
        <%= link_to sprite_icon('del', l(:button_delete)), webhook_path(webhook), :data => {:confirm => l(:text_are_you_sure)}, :method => :delete, :class => 'icon icon-del' %>
27
      </td>
28
    </tr>
29
  <% end %>
30
  </tbody>
31
</table>
32
</div>
33
<% else %>
34
  <p class="nodata"><%= l(:label_no_data) %></p>
35
<% end %>
app/views/webhooks/new.html.erb
1
<h2><%= l :label_webhook_new %></h2>
2

  
3
<%= labelled_form_for @webhook, url: webhooks_path do |f| %>
4
  <%= render :partial => 'webhooks/form', locals: { f: f } %>
5
  <%= submit_tag l(:button_create) %>
6
  <%= link_to l(:button_cancel), webhooks_path %>
7
<% end %>
config/configuration.yml.example
224 224
  # false: switches to default common mark where two or more spaces are required
225 225
  # common_mark_enable_hardbreaks: true
226 226

  
227
  # Webhooks
228
  #
229
  # An optional list of hosts and/or IP addresses and/or IP networks which
230
  # should NOT be valid as webhook targets. You can add your internal IPs and
231
  # hostnames here to avoid possible SSRF attacks.
232
  # webhook_blocklist:
233
  #   - 10.0.0.0/8
234
  #   - 172.16.0.0/12
235
  #   - 192.168.0.0/16
236
  #   - fc00::/7
237
  #   - example.org
238
  #   - "*.example.com"
239

  
227 240
# specific configuration options for production environment
228 241
# that overrides the default ones
229 242
production:
config/icon_source.yml
221 221
- name: unwatch
222 222
  svg: eye-off
223 223
- name: copy-pre-content
224
  svg: clipboard
224
  svg: clipboard
225
- name: webhook
226
  svg: webhook
config/locales/de.yml
845 845
  label_yesterday: gestern
846 846
  label_default_query: Standardabfrage
847 847
  label_progressbar: Fortschrittsbalken
848
  label_webhook_plural: Webhooks
849
  label_webhook_new: Neuer Webhook
850
  label_webhook_edit: Webhook bearbeiten
851
  label_webhook_events: Ereignisse
852

  
853
  webhook_events_issue: Aufgaben
854
  webhook_events_issue_created: Aufgabe angelegt
855
  webhook_events_issue_updated: Aufgabe bearbeitet
856
  webhook_events_issue_deleted: Aufgabe gelöscht
857
  webhook_url_info: Redmine sendet einen POST-Request an diese URL, wenn eines der gewählten Ereignisse in einem der ausgewählten Projekte eintritt.
858
  webhook_secret_info_html: Wenn gesetzt, wird Redmine mit jedem Request eine Hash-Signatur im X-Redmine-Signature-256 header übermitteln, die zur Authentifizierung des Requests herangezogen werden kann.
848 859

  
849 860
  mail_body_account_activation_request: "Ein neuer Benutzer (%{value}) hat sich registriert. Sein Konto wartet auf Ihre Genehmigung:"
850 861
  mail_body_account_information: Ihre Konto-Informationen
config/locales/en.yml
1157 1157
  label_time_by_author: "%{time} by %{author}"
1158 1158
  label_involved_principals: Author / Previous assignee
1159 1159
  label_progressbar: Progress bar
1160
  label_webhook_plural: Webhooks
1161
  label_webhook_new: New webhook
1162
  label_webhook_edit: Edit webhook
1163
  label_webhook_events: Events
1164

  
1165
  webhook_events_issue: Issues
1166
  webhook_events_issue_created: Issue created
1167
  webhook_events_issue_updated: Issue updated
1168
  webhook_events_issue_deleted: Issue deleted
1169
  webhook_url_info: Redmine will send a POST request to this URL whenever one of the selected events occurs in one of the selected projects.
1170
  webhook_secret_info_html: If provided, Redmine will use this to create a hash signature that is sent with each delivery as the value of the X-Redmine-Signature-256 header.
1160 1171

  
1161 1172
  button_login: Login
1162 1173
  button_submit: Submit
config/routes.rb
30 30
  match 'account/activate', :to => 'account#activate', :via => :get
31 31
  get 'account/activation_email', :to => 'account#activation_email', :as => 'activation_email'
32 32

  
33
  resources :webhooks, only: [:index, :new, :create, :edit, :update, :destroy]
34

  
33 35
  match '/news/preview', :controller => 'previews', :action => 'news', :as => 'preview_news', :via => [:get, :post, :put, :patch]
34 36
  match '/issues/preview', :to => 'previews#issue', :as => 'preview_issue', :via => [:get, :post, :put, :patch]
35 37
  match '/preview/text', :to => 'previews#text', :as => 'preview_text', :via => [:get, :post, :put, :patch]
db/migrate/20240923073256_create_webhooks.rb
1
class CreateWebhooks < ActiveRecord::Migration[5.2]
2
  def change
3
    create_table :webhooks do |t|
4
      t.string :url, null: false, limit: 2000
5
      t.string :secret
6
      t.text :events
7
      t.integer :user_id, null: false, index: true
8
      t.boolean :active, null: false, default: false, index: true
9
      t.timestamps
10
    end
11

  
12
    create_table :projects_webhooks do |t|
13
      t.integer :project_id, null: false, index: true
14
      t.integer :webhook_id, null: false, index: true
15
    end
16
  end
17
end
lib/webhook_endpoint_validator.rb
1
# frozen_string_literal: true
2

  
3
require 'uri'
4

  
5
class WebhookEndpointValidator < ActiveModel::EachValidator
6
  def validate_each(record, attribute, value)
7
    return if value.blank?
8

  
9
    unless self.class.safe_webhook_uri?(value)
10
      record.errors.add attribute, :invalid
11
    end
12
  end
13

  
14
  def self.safe_webhook_uri?(value)
15
    uri = value.is_a?(URI) ? value : URI.parse(value)
16
    return false if uri.nil?
17

  
18
    return false unless valid_scheme?(uri.scheme)
19
    return false unless valid_host?(uri.host)
20
    return false unless valid_port?(uri.port)
21

  
22
    true
23
  rescue
24
    Rails.logger.warn { "URI failed webhook safety checks: #{uri}" }
25
    false
26
  end
27

  
28
  def self.valid_port?(port)
29
    !BAD_PORTS.include?(port)
30
  end
31

  
32
  def self.valid_scheme?(scheme)
33
    %w[http https].include?(scheme)
34
  end
35

  
36
  def self.blocked_hosts
37
    @blocked_hosts ||= begin
38
      ips = []
39
      wildcards = []
40
      hosts = []
41

  
42
      Array(Redmine::Configuration['webhook_blocklist']).map(&:to_s).each do |block|
43
        # We try to parse the block as an IP address first...
44
        ips << IPAddr.new(block)
45
      rescue IPAddr::Error
46
        # If that failed, we assume it is a (wildcard) hostname
47
        if block.start_with?('*.')
48
          wildcards << Regexp.escape(block[2..])
49
        else
50
          hosts << Regexp.escape(block)
51
        end
52
      end
53

  
54
      regex_parts = []
55
      regex_parts << "(?:#{hosts.join('|')})" if hosts.any?
56
      regex_parts << "(?:.*\\.)?(?:#{wildcards.join('|')})" if wildcards.any?
57

  
58
      {
59
        ips: ips.freeze,
60
        host: regex_parts.any? ? /\A(?:#{regex_parts.join('|')})\z/i : nil
61
      }.freeze
62
    end
63
  end
64

  
65
  def self.valid_host?(host)
66
    return false if host.blank?
67

  
68
    return false if blocked_hosts[:host]&.match?(host)
69

  
70
    Resolv.each_address(host) do |ip|
71
      ipaddr = IPAddr.new(ip)
72
      return false if ipaddr.link_local? || ipaddr.loopback?
73
      return false if IPAddr.new('224.0.0.0/24').include?(ipaddr) # multicast
74
      return false if blocked_hosts[:ips].any? { |net| net.include?(ipaddr) }
75
    end
76

  
77
    true
78
  end
79

  
80
  # A general port blacklist.  Connections to these ports will not be allowed
81
  # unless the protocol overrides.
82
  #
83
  # This list is to be kept in sync with "bad ports" as defined in the
84
  # WHATWG Fetch standard at https://fetch.spec.whatwg.org/#port-blocking
85
  #
86
  # see also: https://github.com/mozilla/gecko-dev/blob/d55e89d48a8053ce45a74b0ec92c0ff6a9dcc43d/netwerk/base/nsIOService.cpp#L109-L199
87
  #
88
  BAD_PORTS = Set[
89
    1,      # tcpmux
90
    7,      # echo
91
    9,      # discard
92
    11,     # systat
93
    13,     # daytime
94
    15,     # netstat
95
    17,     # qotd
96
    19,     # chargen
97
    20,     # ftp-data
98
    21,     # ftp
99
    22,     # ssh
100
    23,     # telnet
101
    25,     # smtp
102
    37,     # time
103
    42,     # name
104
    43,     # nicname
105
    53,     # domain
106
    69,     # tftp
107
    77,     # priv-rjs
108
    79,     # finger
109
    87,     # ttylink
110
    95,     # supdup
111
    101,    # hostriame
112
    102,    # iso-tsap
113
    103,    # gppitnp
114
    104,    # acr-nema
115
    109,    # pop2
116
    110,    # pop3
117
    111,    # sunrpc
118
    113,    # auth
119
    115,    # sftp
120
    117,    # uucp-path
121
    119,    # nntp
122
    123,    # ntp
123
    135,    # loc-srv / epmap
124
    137,    # netbios
125
    139,    # netbios
126
    143,    # imap2
127
    161,    # snmp
128
    179,    # bgp
129
    389,    # ldap
130
    427,    # afp (alternate)
131
    465,    # smtp (alternate)
132
    512,    # print / exec
133
    513,    # login
134
    514,    # shell
135
    515,    # printer
136
    526,    # tempo
137
    530,    # courier
138
    531,    # chat
139
    532,    # netnews
140
    540,    # uucp
141
    548,    # afp
142
    554,    # rtsp
143
    556,    # remotefs
144
    563,    # nntp+ssl
145
    587,    # smtp (outgoing)
146
    601,    # syslog-conn
147
    636,    # ldap+ssl
148
    989,    # ftps-data
149
    990,    # ftps
150
    993,    # imap+ssl
151
    995,    # pop3+ssl
152
    1719,   # h323gatestat
153
    1720,   # h323hostcall
154
    1723,   # pptp
155
    2049,   # nfs
156
    3659,   # apple-sasl
157
    4045,   # lockd
158
    4190,   # sieve
159
    5060,   # sip
160
    5061,   # sips
161
    6000,   # x11
162
    6566,   # sane-port
163
    6665,   # irc (alternate)
164
    6666,   # irc (alternate)
165
    6667,   # irc (default)
166
    6668,   # irc (alternate)
167
    6669,   # irc (alternate)
168
    6679,   # osaut
169
    6697,   # irc+tls
170
    10080  # amanda
171
  ].freeze
172
end
test/functional/webhooks_controller_test.rb
1
# frozen_string_literal: true
2

  
3
require 'test_helper'
4

  
5
class WebhooksControllerTest < Redmine::ControllerTest
6
  fixtures :projects, :users, :email_addresses, :user_preferences, :members, :member_roles, :roles,
7
           :groups_users,
8
           :trackers, :projects_trackers,
9
           :enabled_modules,
10
           :versions,
11
           :issue_statuses, :issue_categories, :issue_relations, :workflows,
12
           :enumerations,
13
           :issues, :journals, :journal_details
14

  
15
  setup do
16
    @project = Project.find 'ecookbook'
17
    @dlopper = User.find_by_login 'dlopper'
18
    @issue = @project.issues.first
19
    @hook = create_hook
20
    @other_hook = create_hook user: User.find_by_login('admin'), url: 'https://example.com/other/hook'
21
    @request.session[:user_id] = @dlopper.id
22
  end
23

  
24
  test "should require login" do
25
    @request.session[:user_id] = nil
26
    get :index
27
    assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fwebhooks'
28
  end
29

  
30
  test "should get index" do
31
    get :index
32
    assert_response :success
33
    assert_select 'td', text: @hook.url
34
    assert_select 'td', text: @other_hook.url, count: 0
35
  end
36

  
37
  test "should get new" do
38
    get :new
39
    assert_response :success
40
  end
41

  
42
  test "should create webhook" do
43
    assert_difference 'Webhook.count' do
44
      post :create, params: { webhook: { url: 'https://example.com/new/hook', events: %w(issue.created), project_ids: [@project.id] } }
45
    end
46
    assert_redirected_to webhooks_path
47
  end
48

  
49
  test "should get edit" do
50
    get :edit, params: { id: @hook.id }
51
    assert_response :success
52
  end
53

  
54
  test "should update webhook" do
55
    patch :update, params: { id: @hook.id, webhook: { url: 'https://example.com/updated/hook' } }
56
    assert_redirected_to webhooks_path
57
    assert_equal 'https://example.com/updated/hook', @hook.reload.url
58
  end
59

  
60
  test 'edit should not find hook of other user' do
61
    get :edit, params: { id: @other_hook.id }
62
    assert_response :not_found
63
  end
64

  
65
  private
66

  
67
  def create_hook(url: 'https://example.com/some/hook',
68
                  user: User.find_by_login('dlopper'),
69
                  events: %w(issue.created issue.updated),
70
                  projects: [Project.find('ecookbook')])
71
    Webhook.create!(url: url, user: user, events: events, projects: projects)
72
  end
73
end
test/unit/lib/webhook_endpoint_validator_test.rb
1
# frozen_string_literal: true
2

  
3
require 'test_helper'
4

  
5
class WebhookEndpointValidatorTest < ActiveSupport::TestCase
6
  class TestModel
7
    include ActiveModel::Validations
8
    attr_accessor :url
9

  
10
    def initialize(url)
11
      self.url = url
12
    end
13

  
14
    validates :url, webhook_endpoint: true
15
  end
16

  
17
  setup do
18
    Redmine::Configuration.with('webhook_blocklist' => ['*.example.org', '10.0.0.0/8', '192.168.0.0/16']) do
19
      # blocked_hosts is cached as a class variable, so initialize this here to
20
      # make sure it is done before any tests run
21
      WebhookEndpointValidator.blocked_hosts
22
    end
23
  end
24

  
25
  test "should validate url" do
26
    %w[
27
      mailto:user@example.com
28
      foobar
29
      example.com
30
      file://example.com
31
      https://x.example.org/
32
      http://x.example.org/
33
    ].each do |url|
34
      assert_not WebhookEndpointValidator.safe_webhook_uri?(url), "#{url} should be invalid"
35
      record = TestModel.new url
36
      assert_not record.valid?
37
      assert record.errors[:url].any?
38
    end
39

  
40
    assert WebhookEndpointValidator.safe_webhook_uri? 'https://acme.com/some/webhook?foo=bar'
41
    record = TestModel.new 'https://acme.com/some/webhook?foo=bar'
42
    assert record.valid?, record.errors.inspect
43
  end
44

  
45
  test "should validate ports" do
46
    %w[
47
      http://example.com:22
48
      http://example.com:1
49
    ].each do |url|
50
      assert_not WebhookEndpointValidator.safe_webhook_uri?(url), "#{url} should be invalid"
51
    end
52
    %w[
53
      http://example.com
54
      http://example.com:80
55
      http://example.com:443
56
      http://example.com:8080
57
    ].each do |url|
58
      assert WebhookEndpointValidator.safe_webhook_uri? url
59
    end
60
  end
61

  
62
  test "should validate ip addresses" do
63
    %w[
64
      127.0.0.0
65
      127.0.0.1
66
      10.0.0.0
67
      10.0.1.0
68
      169.254.1.9
69
      192.168.2.1
70
      224.0.0.1
71
      ::1/128
72
      fe80::/10
73
    ].each do |ip|
74
      assert_not WebhookEndpointValidator.safe_webhook_uri? ip
75
      h = TestModel.new "http://#{ip}"
76
      assert_not h.valid?, "IP #{ip} should be invalid"
77
      assert h.errors[:url].any?
78
    end
79
  end
80
end
test/unit/webhook_payload_test.rb
1
# frozen_string_literal: true
2

  
3
require 'test_helper'
4

  
5
class WebhookPayloadTest < ActiveSupport::TestCase
6
  include ActiveJob::TestHelper
7

  
8
  fixtures :projects, :users, :trackers, :projects_trackers, :versions,
9
           :issue_statuses, :issue_categories, :issue_relations,
10
           :enumerations, :issues, :journals, :journal_details
11

  
12
  setup do
13
    @dlopper = User.find_by_login 'dlopper'
14
    @project = Project.find 'ecookbook'
15
    @issue = @project.issues.first
16
  end
17

  
18
  test "issue update payload should contain journal" do
19
    @issue.init_journal(@dlopper)
20
    @issue.subject = "new subject"
21
    @issue.save
22
    p = WebhookPayload.new('issue.updated', @issue, @dlopper)
23
    assert h = p.to_h
24
    assert_equal 'issue.updated', h[:type]
25
    assert j = h.dig(:data, :journal)
26
    assert_equal 'Dave Lopper', j[:user][:name]
27
    assert i = h.dig(:data, :issue)
28
    assert_equal 'new subject', i[:subject], i.inspect
29
  end
30

  
31
  test "should compute payload of deleted issue" do
32
    @issue.destroy
33
    p = WebhookPayload.new('issue.deleted', @issue, @dlopper)
34
    assert h = p.to_h
35
    assert_equal 'issue.deleted', h[:type]
36
    assert_nil h.dig(:data, :journal)
37
    assert i = h.dig(:data, :issue)
38
    assert_equal @issue.subject, i[:subject], i.inspect
39
  end
40
end
test/unit/webhook_test.rb
1
# frozen_string_literal: true
2

  
3
require 'test_helper'
4
require 'pp'
5

  
6
class WebhookTest < ActiveSupport::TestCase
7
  include ActiveJob::TestHelper
8

  
9
  fixtures :projects, :users, :email_addresses, :user_preferences, :members, :member_roles, :roles,
10
           :groups_users,
11
           :trackers, :projects_trackers,
12
           :enabled_modules,
13
           :versions,
14
           :issue_statuses, :issue_categories, :issue_relations, :workflows,
15
           :enumerations,
16
           :issues, :journals, :journal_details
17

  
18
  setup do
19
    # Set ActiveJob to use the test adapter
20
    @original_adapter = ActiveJob::Base.queue_adapter
21
    ActiveJob::Base.queue_adapter = :test
22

  
23
    @project = Project.find 'ecookbook'
24
    @dlopper = User.find_by_login 'dlopper'
25
    @issue = @project.issues.first
26
    Redmine::Configuration.with('webhook_blocklist' => ['*.example.org', '10.0.0.0/8', '192.168.0.0/16']) do
27
      # blocked_hosts is cached as a class variable, so initialize this here to
28
      # make sure it is done before any tests run
29
      WebhookEndpointValidator.blocked_hosts
30
    end
31
  end
32

  
33
  teardown do
34
    # Restore the original adapter
35
    ActiveJob::Base.queue_adapter = @original_adapter
36
  end
37

  
38
  test "should validate url" do
39
    %w[
40
      mailto:user@example.com
41
      https://x.example.org/
42
      https://example.org/
43
      https://x.example.org/foo/bar?a=b
44
      foobar
45
      example.com
46
      https://10.1.0.12/
47
    ].each do |url|
48
      hook = Webhook.new(url: url)
49
      assert_not hook.valid?, "URL '#{url}' should be invalid"
50
      assert hook.errors[:url].any?
51
    end
52
  end
53

  
54
  test "should validate secret length" do
55
    hook = Webhook.new secret: 'abdc' * 100
56
    assert_not hook.valid?
57
    assert hook.errors[:secret].any?
58
  end
59

  
60
  test "should validate events" do
61
    Webhook.new.setable_event_names.each do |event|
62
      h = create_hook events: [event]
63
      assert h.persisted?
64
    end
65
    hook = Webhook.new(events: ['issue.created', 'invalid.event'])
66
    assert_not hook.valid?
67
    assert hook.errors[:events].any?
68
    assert_raise(ActiveRecord::SerializationTypeMismatch){ Webhook.new(events: 'issue.created') }
69
  end
70

  
71
  test "should clean up project list on save" do
72
    h = create_hook
73
    assert_equal [@project], h.projects
74
    @project.memberships.destroy_all
75
    @project.update is_public: false
76

  
77
    h.reload
78
    h.save
79
    h.reload
80
    assert_equal [], h.projects
81
  end
82

  
83
  test "should check ip address at run time" do
84
    %w[
85
      127.0.0.0
86
      127.0.0.1
87
      10.0.0.0
88
      10.0.1.0
89
      169.254.1.9
90
      192.168.2.1
91
      224.0.0.1
92
      ::1/128
93
      fe80::/10
94
    ].each do |ip|
95
      h = Webhook.new url: "http://#{ip}"
96
      assert_not h.valid?, "IP #{ip} should be invalid"
97
      assert h.errors[:url].any?
98
    end
99
  end
100

  
101
  test "should find hooks for issue" do
102
    hook = create_hook
103
    assert @issue.visible?(hook.user)
104
    assert_equal [hook], Webhook.hooks_for('issue.created', @issue)
105
    assert_equal [], Webhook.hooks_for('issue.deleted', @issue)
106
    @issue.update_column :project_id, 99
107
    assert_equal [], Webhook.hooks_for('issue.created', @issue)
108
  end
109

  
110
  test "should not find inactive hook" do
111
    hook = create_hook active: false
112
    assert @issue.visible?(hook.user)
113
    assert_equal [], Webhook.hooks_for('issue.created', @issue)
114
  end
115

  
116
  test "should not find hook of inactive user" do
117
    admin = User.find_by_login 'admin'
118
    hook = create_hook user: admin
119
    assert_equal [hook], Webhook.hooks_for('issue.created', @issue)
120
    admin.update_column :status, 3
121
    assert_equal [], Webhook.hooks_for('issue.created', @issue)
122
  end
123

  
124
  test "should find hook for deleted issue" do
125
    hook = create_hook events: ['issue.deleted']
126
    @issue.destroy
127
    assert_equal [hook], Webhook.hooks_for('issue.deleted', @issue)
128
  end
129

  
130
  test "schedule should enqueue jobs for hooks" do
131
    hook = create_hook
132
    assert_enqueued_jobs 1 do
133
      assert_enqueued_with(job: WebhookJob) do
134
        Webhook.trigger('issue.created', @issue)
135
      end
136
    end
137
  end
138

  
139
  test "should not enqueue job for inactive hook" do
140
    hook = create_hook active: false
141
    assert_no_enqueued_jobs do
142
      Webhook.trigger('issue.created', @issue)
143
    end
144
  end
145

  
146
  test "should compute payload" do
147
    hook = create_hook
148
    payload = hook.payload('issue.created', @issue)
149
    assert_equal 'issue.created', payload[:type]
150
    assert_equal @issue.id, payload.dig(:data, :issue, :id)
151
  end
152

  
153
  test "should compute correct signature" do
154
    # we're implementing the same signature mechanism as GitHub, so might as well re-use their
155
    # example. https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
156
    e = Webhook::Executor.new('https://example.com', 'Hello, World!', "It's a Secret to Everybody")
157
    assert_equal "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17", e.compute_signature
158
  end
159

  
160
  private
161

  
162
  def create_hook(url: 'https://example.com/some/hook', user: User.find_by_login('dlopper'), projects: [Project.find('ecookbook')], events: ['issue.created'], active: true)
163
    Webhook.create!(url: url, user: user, projects: projects, events: events, active: active)
164
  end
165
end
    (1-1/1)