Introduces issue webhooks (#29664):

* users can set up hooks for issue creation, update and deletion events, for any number of projects
* hooks run in the context of the creating user, and only if the object in question is visible to that user
* the actual HTTP call is done in ActiveJob
* webhook calls are optionally signed the same way GitHub does

Patch by Jens Krämer (user:jkraemer).



git-svn-id: https://svn.redmine.org/redmine/trunk@24034 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
Marius Balteanu
2025-10-07 06:49:14 +00:00
parent 824f3844fc
commit d90d192f48
24 changed files with 986 additions and 1 deletions

View File

@@ -41,6 +41,9 @@ gem 'rqrcode'
gem "html-pipeline", "~> 2.13.2"
gem "sanitize", "~> 6.0"
# Triggering of Webhooks
gem "rest-client", "~> 2.1"
# Optional gem for LDAP authentication
group :ldap do
gem 'net-ldap', '~> 0.17.0'

View File

@@ -547,6 +547,11 @@
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"/>
<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"/>
</symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--webhook">
<path d="M4.876 13.61a4 4 0 1 0 6.124 3.39h6"/>
<path d="M15.066 20.502a4 4 0 1 0 1.934 -7.502c-.706 0 -1.424 .179 -2 .5l-3 -5.5"/>
<path d="M16 8a4 4 0 1 0 -8 0c0 1.506 .77 2.818 2 3.5l-3 5.5"/>
</symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--wiki-page">
<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"/>
<path d="M13 8l2 0"/>

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,59 @@
# frozen_string_literal: true
class WebhooksController < ApplicationController
self.main_menu = false
before_action :require_login
before_action :find_webhook, only: [:edit, :update, :destroy]
require_sudo_mode :create, :update, :destroy
def index
@webhooks = webhooks.order(:url)
end
def new
@webhook = Webhook.new
end
def edit
end
def create
@webhook = webhooks.build(webhook_params)
if @webhook.save
redirect_to webhooks_path
else
render :new
end
end
def update
if @webhook.update(webhook_params)
redirect_to webhooks_path
else
render :edit
end
end
def destroy
@webhook.destroy
redirect_to webhooks_path
end
private
def webhook_params
params.require(:webhook).permit(:url, :secret, :active, events: [], project_ids: [])
end
def find_webhook
@webhook = webhooks.find(params[:id])
rescue ActiveRecord::RecordNotFound
render_404
end
def webhooks
User.current.webhooks
end
end

16
app/jobs/webhook_job.rb Normal file
View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true
class WebhookJob < ApplicationJob
def perform(hook_id, payload_json)
if hook = Webhook.find_by_id(hook_id)
if hook.user&.active?
User.current = hook.user
hook.call payload_json
else
Rails.logger.debug { "WebhookJob: user with id=#{hook.user_id} is not active" }
end
else
Rails.logger.debug { "WebhookJob: couldn't find hook with id=#{hook_id}" }
end
end
end

View File

@@ -130,6 +130,10 @@ class Issue < ApplicationRecord
after_create_commit :add_auto_watcher
after_commit :create_parent_issue_journal
after_create_commit ->{ Webhook.trigger('issue.created', self) }
after_update_commit ->{ Webhook.trigger('issue.updated', self) }
after_destroy_commit ->{ Webhook.trigger('issue.deleted', self) }
# Returns a SQL conditions string used to find all issues visible by the specified user
def self.visible_condition(user, options={})
Project.allowed_to_condition(user, :view_issues, options) do |role, user|

View File

@@ -60,6 +60,8 @@ class Project < ApplicationRecord
:class_name => 'IssueCustomField',
:join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
:association_foreign_key => 'custom_field_id'
has_and_belongs_to_many :webhooks
# Default Custom Query
belongs_to :default_issue_query, :class_name => 'IssueQuery'

View File

@@ -102,6 +102,8 @@ class User < Principal
has_one :api_token, lambda {where "#{table.name}.action='api'"}, :class_name => 'Token'
has_many :email_addresses, :dependent => :delete_all
has_many :reactions, dependent: :delete_all
has_many :webhooks, dependent: :destroy
belongs_to :auth_source
scope :logged, lambda {where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}")}

111
app/models/webhook.rb Normal file
View File

@@ -0,0 +1,111 @@
# frozen_string_literal: true
require 'rest-client'
class Webhook < ApplicationRecord
Executor = Struct.new(:url, :payload, :secret) do
# @return [RestClient::Response] if the POST request was successful
# @raise [RestClient::Exception, Exception] a `RestClient::Exception` if an
# unexpected (i.e. non-successful) response status was set; it may contain
# the server response. For connection errors, we may raise any other
# exception.
def call
# DNS and therefore destination IPs might have changed since the record was saved, so check the URL, again.
raise URI::BadURIError unless WebhookEndpointValidator.safe_webhook_uri?(url)
headers = { accept: '*/*', content_type: :json, user_agent: 'Redmine' }
if secret.present?
headers['X-Redmine-Signature-256'] = compute_signature
end
Rails.logger.debug { "Webhook: POST #{url}" }
RestClient.post url, payload, headers
end
# Computes the HMAC signature for the given payload and secret.
# https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
def compute_signature
'sha256=' + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret, payload)
end
end
belongs_to :user
has_and_belongs_to_many :projects
validates :url, presence: true, webhook_endpoint: true, length: { maximum: 2000 }
validates :secret, length: { maximum: 255 }, allow_blank: true
validate :check_events_array
serialize :events, coder: YAML, type: Array
scope :active, -> { where(active: true) }
before_validation ->(hook){ hook.projects = hook.projects.to_a.select{|p| p.visible?(hook.user) } }
# Triggers the given event for the given object, scheduling qualifying hooks
# to be called.
def self.trigger(event, object)
hooks_for(event, object).each do |hook|
payload = hook.payload(event, object)
WebhookJob.perform_later(hook.id, payload.to_json)
end
end
# Finds hooks for the given event and object.
# Returns an array of hooks that are active, have the given event in their list
# of events, and whose user can see the object.
#
# Object must have a project_id and respond to visible?(user)
def self.hooks_for(event, object)
Webhook.active
.joins("INNER JOIN projects_webhooks on projects_webhooks.webhook_id = webhooks.id")
.eager_load(:user)
.where(users: { status: User::STATUS_ACTIVE }, projects_webhooks: { project_id: object.project_id })
.to_a.select do |hook|
hook.events.include?(event) && object.visible?(hook.user)
end
end
def setable_projects
Project.visible
end
def setable_events
WebhookPayload::EVENTS
end
def setable_event_names
setable_events.map{|type, actions| actions.map{|action| "#{type}.#{action}"}}.flatten
end
# computes the payload. this happens when the hook is triggered, and the
# payload is stored as part of the hook job definition.
# event must be of the form 'type.action' (like 'issue.created')
def payload(event, object)
WebhookPayload.new(event, object, user).to_h
end
# POSTs the given payload to the hook URL, returns true if successful, false otherwise.
#
# logs any unsuccessful hook calls, but does not raise
def call(payload_json)
Executor.new(url, payload_json, secret).call
true
rescue => e
Rails.logger.warn { "Webhook Error: #{e.message} (#{e.class})\n#{e.backtrace.join "\n"}" }
false
end
private
def check_events_array
unless events.is_a?(Array)
errors.add(:events, :invalid)
return
end
events.reject!(&:blank?)
if (events - setable_event_names).any?
errors.add(:events, :invalid)
end
end
end

View File

@@ -0,0 +1,101 @@
# frozen_string_literal: true
# Webhook payload
class WebhookPayload
attr_accessor :event, :object, :user
def initialize(event, object, user)
self.event = event
self.object = object
self.user = user
end
EVENTS = {
issue: %w[created updated deleted]
}
def to_h
type, action = event.split('.')
if EVENTS[type.to_sym].include?(action)
send("#{type}_payload", action)
else
raise ArgumentError, "invalid event: #{event}"
end
end
private
def issue_payload(action)
issue = object
if issue.current_journal.present?
journal = issue.journals.visible(user).find_by_id(issue.current_journal.id)
end
ts = case action
when 'created'
issue.created_on
when 'deleted'
Time.now
else
journal&.created_on || issue.updated_on
end
h = {
type: event,
timestamp: ts.iso8601,
data: {
issue: ApiRenderer.new("app/views/issues/show.api.rsb", user).to_h(issue: issue)
}
}
if action == 'updated' && journal.present?
h[:data][:journal] = journal_payload(journal)
end
h
end
def journal_payload(journal)
{
id: journal.id,
created_on: journal.created_on.iso8601,
notes: journal.notes,
user: {
id: journal.user.id,
name: journal.user.name,
},
details: journal.visible_details(user).map do |d|
{
property: d.property,
prop_key: d.prop_key,
old_value: d.old_value,
value: d.value,
}
end
}
end
# given a path to an API template (relative to RAILS_ROOT), renders it and returns the resulting hash
class ApiRenderer
include ApplicationHelper
include CustomFieldsHelper
attr_accessor :path, :params, :user
DummyRequest = Struct.new(:params)
def initialize(path, user, params = nil)
self.path = path
self.user = user
self.params = params || {}
end
def to_h(**ivars)
req = DummyRequest.new(params)
api = Redmine::Views::Builders::Json.new(req, nil)
ivars.each { |k, v| instance_variable_set :"@#{k}", v }
original_user = User.current
begin
User.current = self.user
instance_eval(File.read(Rails.root.join(path)), path, 1)
ensure
User.current = original_user
end
end
end
end

View File

@@ -0,0 +1,44 @@
<%= error_messages_for @webhook %>
<div class="splitcontent">
<div class="splitcontentleft">
<div class="box tabular">
<p><%= f.text_field :url, required: true, size: 60 %>
<em class="info"><%= l :webhook_url_info %></em>
</p>
<p>
<%= f.text_field :secret %>
<em class="info"><%= raw l :webhook_secret_info_html %></em>
</p>
<p><%= f.check_box :active %></p>
</div>
<h3><%= l :label_webhook_events %></h3>
<div class="box tabular" id="events">
<% @webhook.setable_events.keys.sort.each do |type| %>
<fieldset id="<%= type %>_events"><legend><%= toggle_checkboxes_link("##{type}_events\ input") %><%= l_or_humanize(type, prefix: 'webhook_events_') %></legend>
<% @webhook.setable_events[type].each do |action| %>
<% name = "#{type}.#{action}" %>
<label class="floating">
<%= check_box_tag 'webhook[events][]', name, @webhook.events.include?(name), id: "webhook_events_#{name}" %>
<%= l_or_humanize(name.tr('.', '_'), :prefix => 'webhook_events_') %>
</label>
<% end %>
</fieldset>
<% end %>
<br /><%= check_all_links 'events' %>
<%= hidden_field_tag 'webhook[events][]', '' %>
</div>
</div>
<div class="splitcontentright">
<fieldset class="box" id="webhook_project_ids"><legend><%= toggle_checkboxes_link("#webhook_project_ids input[type=checkbox]") %><%= l(:label_project_plural) %></legend>
<% project_ids = @webhook.project_ids.to_a %>
<%= render_project_nested_lists(@webhook.setable_projects) do |p|
content_tag('label', check_box_tag('webhook[project_ids][]', p.id, project_ids.include?(p.id), :id => nil) + ' ' + h(p))
end %>
<%= hidden_field_tag('webhook[project_ids][]', '', :id => nil) %>
</fieldset>
</div>
</div>

View File

@@ -0,0 +1,6 @@
<h2><%= l :label_webhook_edit %></h2>
<%= labelled_form_for @webhook, html: { method: :patch } do |f| %>
<%= render :partial => 'form', :locals => { :f => f } %>
<%= submit_tag l(:button_save) %>
<% end %>

View File

@@ -0,0 +1,35 @@
<div class="contextual">
<%= link_to sprite_icon('add', l(:label_webhook_new)), new_webhook_path, class: 'icon icon-add' %>
</div>
<%= title l :label_webhook_plural %>
<% if @webhooks.any? %>
<div class="autoscroll">
<table class="list">
<thead><tr>
<th><%= l :field_active %></th>
<th><%= l :label_url %></th>
<th><%= l :label_webhook_events %></th>
<th><%= l :label_project_plural %></th>
<th></th>
</tr></thead>
<tbody>
<% @webhooks.each do |webhook| %>
<tr id="webhook_<%= webhook.id %>" class="<%= cycle("odd", "even") %>">
<td><%= webhook.active ? l(:general_text_Yes) : l(:general_text_No) %></td>
<td><%= truncate webhook.url, length: 40 %></td>
<td><%= safe_join webhook.events.map{|e| content_tag :code, e }, ', ' %></td>
<td><%= safe_join webhook.projects.visible.map{|p| link_to_project(p) }, ', ' %></td>
<td class="buttons">
<%= link_to sprite_icon('edit', l(:button_edit)), edit_webhook_path(webhook), class: 'icon icon-edit' %>
<%= link_to sprite_icon('del', l(:button_delete)), webhook_path(webhook), :data => {:confirm => l(:text_are_you_sure)}, :method => :delete, :class => 'icon icon-del' %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% end %>

View File

@@ -0,0 +1,7 @@
<h2><%= l :label_webhook_new %></h2>
<%= labelled_form_for @webhook, url: webhooks_path do |f| %>
<%= render :partial => 'webhooks/form', locals: { f: f } %>
<%= submit_tag l(:button_create) %>
<%= link_to l(:button_cancel), webhooks_path %>
<% end %>

View File

@@ -224,6 +224,19 @@ default:
# false: switches to default common mark where two or more spaces are required
# common_mark_enable_hardbreaks: true
# Webhooks
#
# An optional list of hosts and/or IP addresses and/or IP networks which
# should NOT be valid as webhook targets. You can add your internal IPs and
# hostnames here to avoid possible SSRF attacks.
# webhook_blocklist:
# - 10.0.0.0/8
# - 172.16.0.0/12
# - 192.168.0.0/16
# - fc00::/7
# - example.org
# - "*.example.com"
# specific configuration options for production environment
# that overrides the default ones
production:

View File

@@ -241,4 +241,6 @@
- name: loader
svg: loader-2
- name: hourglass
svg: hourglass
svg: hourglass
- name: webhook
svg: webhook

View File

@@ -845,6 +845,17 @@ de:
label_yesterday: gestern
label_default_query: Standardabfrage
label_progressbar: Fortschrittsbalken
label_webhook_plural: Webhooks
label_webhook_new: Neuer Webhook
label_webhook_edit: Webhook bearbeiten
label_webhook_events: Ereignisse
webhook_events_issue: Aufgaben
webhook_events_issue_created: Aufgabe angelegt
webhook_events_issue_updated: Aufgabe bearbeitet
webhook_events_issue_deleted: Aufgabe gelöscht
webhook_url_info: Redmine sendet einen POST-Request an diese URL, wenn eines der gewählten Ereignisse in einem der ausgewählten Projekte eintritt.
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.
mail_body_account_activation_request: "Ein neuer Benutzer (%{value}) hat sich registriert. Sein Konto wartet auf Ihre Genehmigung:"
mail_body_account_information: Ihre Konto-Informationen

View File

@@ -1171,6 +1171,17 @@ en:
label_oauth_admin_access: Administration
label_oauth_application_plural: Applications
label_oauth_authorized_application_plural: Authorized applications
label_webhook_plural: Webhooks
label_webhook_new: New webhook
label_webhook_edit: Edit webhook
label_webhook_events: Events
webhook_events_issue: Issues
webhook_events_issue_created: Issue created
webhook_events_issue_updated: Issue updated
webhook_events_issue_deleted: Issue deleted
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.
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.
button_login: Login
button_submit: Submit

View File

@@ -35,6 +35,8 @@ Rails.application.routes.draw do
match 'account/activate', :to => 'account#activate', :via => :get
get 'account/activation_email', :to => 'account#activation_email', :as => 'activation_email'
resources :webhooks, only: [:index, :new, :create, :edit, :update, :destroy]
match '/news/preview', :controller => 'previews', :action => 'news', :as => 'preview_news', :via => [:get, :post, :put, :patch]
match '/issues/preview', :to => 'previews#issue', :as => 'preview_issue', :via => [:get, :post, :put, :patch]
match '/preview/text', :to => 'previews#text', :as => 'preview_text', :via => [:get, :post, :put, :patch]

View File

@@ -0,0 +1,17 @@
class CreateWebhooks < ActiveRecord::Migration[8.0]
def change
create_table :webhooks do |t|
t.string :url, null: false, limit: 2000
t.string :secret
t.text :events
t.integer :user_id, null: false, index: true
t.boolean :active, null: false, default: false, index: true
t.timestamps
end
create_table :projects_webhooks do |t|
t.integer :project_id, null: false, index: true
t.integer :webhook_id, null: false, index: true
end
end
end

View File

@@ -0,0 +1,172 @@
# frozen_string_literal: true
require 'uri'
class WebhookEndpointValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank?
unless self.class.safe_webhook_uri?(value)
record.errors.add attribute, :invalid
end
end
def self.safe_webhook_uri?(value)
uri = value.is_a?(URI) ? value : URI.parse(value)
return false if uri.nil?
return false unless valid_scheme?(uri.scheme)
return false unless valid_host?(uri.host)
return false unless valid_port?(uri.port)
true
rescue
Rails.logger.warn { "URI failed webhook safety checks: #{uri}" }
false
end
def self.valid_port?(port)
!BAD_PORTS.include?(port)
end
def self.valid_scheme?(scheme)
%w[http https].include?(scheme)
end
def self.blocked_hosts
@blocked_hosts ||= begin
ips = []
wildcards = []
hosts = []
Array(Redmine::Configuration['webhook_blocklist']).map(&:to_s).each do |block|
# We try to parse the block as an IP address first...
ips << IPAddr.new(block)
rescue IPAddr::Error
# If that failed, we assume it is a (wildcard) hostname
if block.start_with?('*.')
wildcards << Regexp.escape(block[2..])
else
hosts << Regexp.escape(block)
end
end
regex_parts = []
regex_parts << "(?:#{hosts.join('|')})" if hosts.any?
regex_parts << "(?:.*\\.)?(?:#{wildcards.join('|')})" if wildcards.any?
{
ips: ips.freeze,
host: regex_parts.any? ? /\A(?:#{regex_parts.join('|')})\z/i : nil
}.freeze
end
end
def self.valid_host?(host)
return false if host.blank?
return false if blocked_hosts[:host]&.match?(host)
Resolv.each_address(host) do |ip|
ipaddr = IPAddr.new(ip)
return false if ipaddr.link_local? || ipaddr.loopback?
return false if IPAddr.new('224.0.0.0/24').include?(ipaddr) # multicast
return false if blocked_hosts[:ips].any? { |net| net.include?(ipaddr) }
end
true
end
# A general port blacklist. Connections to these ports will not be allowed
# unless the protocol overrides.
#
# This list is to be kept in sync with "bad ports" as defined in the
# WHATWG Fetch standard at https://fetch.spec.whatwg.org/#port-blocking
#
# see also: https://github.com/mozilla/gecko-dev/blob/d55e89d48a8053ce45a74b0ec92c0ff6a9dcc43d/netwerk/base/nsIOService.cpp#L109-L199
#
BAD_PORTS = Set[
1, # tcpmux
7, # echo
9, # discard
11, # systat
13, # daytime
15, # netstat
17, # qotd
19, # chargen
20, # ftp-data
21, # ftp
22, # ssh
23, # telnet
25, # smtp
37, # time
42, # name
43, # nicname
53, # domain
69, # tftp
77, # priv-rjs
79, # finger
87, # ttylink
95, # supdup
101, # hostriame
102, # iso-tsap
103, # gppitnp
104, # acr-nema
109, # pop2
110, # pop3
111, # sunrpc
113, # auth
115, # sftp
117, # uucp-path
119, # nntp
123, # ntp
135, # loc-srv / epmap
137, # netbios
139, # netbios
143, # imap2
161, # snmp
179, # bgp
389, # ldap
427, # afp (alternate)
465, # smtp (alternate)
512, # print / exec
513, # login
514, # shell
515, # printer
526, # tempo
530, # courier
531, # chat
532, # netnews
540, # uucp
548, # afp
554, # rtsp
556, # remotefs
563, # nntp+ssl
587, # smtp (outgoing)
601, # syslog-conn
636, # ldap+ssl
989, # ftps-data
990, # ftps
993, # imap+ssl
995, # pop3+ssl
1719, # h323gatestat
1720, # h323hostcall
1723, # pptp
2049, # nfs
3659, # apple-sasl
4045, # lockd
4190, # sieve
5060, # sip
5061, # sips
6000, # x11
6566, # sane-port
6665, # irc (alternate)
6666, # irc (alternate)
6667, # irc (default)
6668, # irc (alternate)
6669, # irc (alternate)
6679, # osaut
6697, # irc+tls
10080 # amanda
].freeze
end

View File

@@ -0,0 +1,73 @@
# frozen_string_literal: true
require 'test_helper'
class WebhooksControllerTest < Redmine::ControllerTest
fixtures :projects, :users, :email_addresses, :user_preferences, :members, :member_roles, :roles,
:groups_users,
:trackers, :projects_trackers,
:enabled_modules,
:versions,
:issue_statuses, :issue_categories, :issue_relations, :workflows,
:enumerations,
:issues, :journals, :journal_details
setup do
@project = Project.find 'ecookbook'
@dlopper = User.find_by_login 'dlopper'
@issue = @project.issues.first
@hook = create_hook
@other_hook = create_hook user: User.find_by_login('admin'), url: 'https://example.com/other/hook'
@request.session[:user_id] = @dlopper.id
end
test "should require login" do
@request.session[:user_id] = nil
get :index
assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fwebhooks'
end
test "should get index" do
get :index
assert_response :success
assert_select 'td', text: @hook.url
assert_select 'td', text: @other_hook.url, count: 0
end
test "should get new" do
get :new
assert_response :success
end
test "should create webhook" do
assert_difference 'Webhook.count' do
post :create, params: { webhook: { url: 'https://example.com/new/hook', events: %w(issue.created), project_ids: [@project.id] } }
end
assert_redirected_to webhooks_path
end
test "should get edit" do
get :edit, params: { id: @hook.id }
assert_response :success
end
test "should update webhook" do
patch :update, params: { id: @hook.id, webhook: { url: 'https://example.com/updated/hook' } }
assert_redirected_to webhooks_path
assert_equal 'https://example.com/updated/hook', @hook.reload.url
end
test 'edit should not find hook of other user' do
get :edit, params: { id: @other_hook.id }
assert_response :not_found
end
private
def create_hook(url: 'https://example.com/some/hook',
user: User.find_by_login('dlopper'),
events: %w(issue.created issue.updated),
projects: [Project.find('ecookbook')])
Webhook.create!(url: url, user: user, events: events, projects: projects)
end
end

View File

@@ -0,0 +1,82 @@
# frozen_string_literal: true
require 'test_helper'
class WebhookEndpointValidatorTest < ActiveSupport::TestCase
class TestModel
include ActiveModel::Validations
attr_accessor :url
def initialize(url)
self.url = url
end
validates :url, webhook_endpoint: true
end
setup do
WebhookEndpointValidator.class_eval do
@blocked_hosts = nil
end
end
test "should validate url" do
Redmine::Configuration.with('webhook_blocklist' => ['*.example.org', '10.0.0.0/8', '192.168.0.0/16']) do
%w[
mailto:user@example.com
foobar
example.com
file://example.com
https://x.example.org/
http://x.example.org/
].each do |url|
assert_not WebhookEndpointValidator.safe_webhook_uri?(url), "#{url} should be invalid"
record = TestModel.new url
assert_not record.valid?
assert record.errors[:url].any?
end
assert WebhookEndpointValidator.safe_webhook_uri? 'https://acme.com/some/webhook?foo=bar'
record = TestModel.new 'https://acme.com/some/webhook?foo=bar'
assert record.valid?, record.errors.inspect
end
end
test "should validate ports" do
%w[
http://example.com:22
http://example.com:1
].each do |url|
assert_not WebhookEndpointValidator.safe_webhook_uri?(url), "#{url} should be invalid"
end
%w[
http://example.com
http://example.com:80
http://example.com:443
http://example.com:8080
].each do |url|
assert WebhookEndpointValidator.safe_webhook_uri? url
end
end
test "should validate ip addresses" do
Redmine::Configuration.with('webhook_blocklist' => ['*.example.org', '10.0.0.0/8', '192.168.0.0/16']) do
%w[
127.0.0.0
127.0.0.1
10.0.0.0
10.0.1.0
169.254.1.9
192.168.2.1
224.0.0.1
::1/128
fe80::/10
].each do |ip|
assert_not WebhookEndpointValidator.safe_webhook_uri? ip
h = TestModel.new "http://#{ip}"
assert_not h.valid?, "IP #{ip} should be invalid"
assert h.errors[:url].any?
end
end
end
end

View File

@@ -0,0 +1,40 @@
# frozen_string_literal: true
require 'test_helper'
class WebhookPayloadTest < ActiveSupport::TestCase
include ActiveJob::TestHelper
fixtures :projects, :users, :trackers, :projects_trackers, :versions,
:issue_statuses, :issue_categories, :issue_relations,
:enumerations, :issues, :journals, :journal_details
setup do
@dlopper = User.find_by_login 'dlopper'
@project = Project.find 'ecookbook'
@issue = @project.issues.first
end
test "issue update payload should contain journal" do
@issue.init_journal(@dlopper)
@issue.subject = "new subject"
@issue.save
p = WebhookPayload.new('issue.updated', @issue, @dlopper)
assert h = p.to_h
assert_equal 'issue.updated', h[:type]
assert j = h.dig(:data, :journal)
assert_equal 'Dave Lopper', j[:user][:name]
assert i = h.dig(:data, :issue)
assert_equal 'new subject', i[:subject], i.inspect
end
test "should compute payload of deleted issue" do
@issue.destroy
p = WebhookPayload.new('issue.deleted', @issue, @dlopper)
assert h = p.to_h
assert_equal 'issue.deleted', h[:type]
assert_nil h.dig(:data, :journal)
assert i = h.dig(:data, :issue)
assert_equal @issue.subject, i[:subject], i.inspect
end
end

167
test/unit/webhook_test.rb Normal file
View File

@@ -0,0 +1,167 @@
# frozen_string_literal: true
require 'test_helper'
require 'pp'
class WebhookTest < ActiveSupport::TestCase
include ActiveJob::TestHelper
fixtures :projects, :users, :email_addresses, :user_preferences, :members, :member_roles, :roles,
:groups_users,
:trackers, :projects_trackers,
:enabled_modules,
:versions,
:issue_statuses, :issue_categories, :issue_relations, :workflows,
:enumerations,
:issues, :journals, :journal_details
setup do
# Set ActiveJob to use the test adapter
@original_adapter = ActiveJob::Base.queue_adapter
ActiveJob::Base.queue_adapter = :test
@project = Project.find 'ecookbook'
@dlopper = User.find_by_login 'dlopper'
@issue = @project.issues.first
WebhookEndpointValidator.class_eval do
@blocked_hosts = nil
end
end
teardown do
# Restore the original adapter
ActiveJob::Base.queue_adapter = @original_adapter
end
test "should validate url" do
Redmine::Configuration.with('webhook_blocklist' => ['*.example.org', '10.0.0.0/8', '192.168.0.0/16']) do
%w[
mailto:user@example.com
https://x.example.org/
https://example.org/
https://x.example.org/foo/bar?a=b
foobar
example.com
https://10.1.0.12/
].each do |url|
hook = Webhook.new(url: url)
assert_not hook.valid?, "URL '#{url}' should be invalid"
assert hook.errors[:url].any?
end
end
end
test "should validate secret length" do
hook = Webhook.new secret: 'abdc' * 100
assert_not hook.valid?
assert hook.errors[:secret].any?
end
test "should validate events" do
Webhook.new.setable_event_names.each do |event|
h = create_hook events: [event]
assert h.persisted?
end
hook = Webhook.new(events: ['issue.created', 'invalid.event'])
assert_not hook.valid?
assert hook.errors[:events].any?
assert_raise(ActiveRecord::SerializationTypeMismatch){ Webhook.new(events: 'issue.created') }
end
test "should clean up project list on save" do
h = create_hook
assert_equal [@project], h.projects
@project.memberships.destroy_all
@project.update is_public: false
h.reload
h.save
h.reload
assert_equal [], h.projects
end
test "should check ip address at run time" do
Redmine::Configuration.with('webhook_blocklist' => ['*.example.org', '10.0.0.0/8', '192.168.0.0/16']) do
%w[
127.0.0.0
127.0.0.1
10.0.0.0
10.0.1.0
169.254.1.9
192.168.2.1
224.0.0.1
::1/128
fe80::/10
].each do |ip|
h = Webhook.new url: "http://#{ip}"
assert_not h.valid?, "IP #{ip} should be invalid"
assert h.errors[:url].any?
end
end
end
test "should find hooks for issue" do
hook = create_hook
assert @issue.visible?(hook.user)
assert_equal [hook], Webhook.hooks_for('issue.created', @issue)
assert_equal [], Webhook.hooks_for('issue.deleted', @issue)
@issue.update_column :project_id, 99
assert_equal [], Webhook.hooks_for('issue.created', @issue)
end
test "should not find inactive hook" do
hook = create_hook active: false
assert @issue.visible?(hook.user)
assert_equal [], Webhook.hooks_for('issue.created', @issue)
end
test "should not find hook of inactive user" do
admin = User.find_by_login 'admin'
hook = create_hook user: admin
assert_equal [hook], Webhook.hooks_for('issue.created', @issue)
admin.update_column :status, 3
assert_equal [], Webhook.hooks_for('issue.created', @issue)
end
test "should find hook for deleted issue" do
hook = create_hook events: ['issue.deleted']
@issue.destroy
assert_equal [hook], Webhook.hooks_for('issue.deleted', @issue)
end
test "schedule should enqueue jobs for hooks" do
hook = create_hook
assert_enqueued_jobs 1 do
assert_enqueued_with(job: WebhookJob) do
Webhook.trigger('issue.created', @issue)
end
end
end
test "should not enqueue job for inactive hook" do
hook = create_hook active: false
assert_no_enqueued_jobs do
Webhook.trigger('issue.created', @issue)
end
end
test "should compute payload" do
hook = create_hook
payload = hook.payload('issue.created', @issue)
assert_equal 'issue.created', payload[:type]
assert_equal @issue.id, payload.dig(:data, :issue, :id)
end
test "should compute correct signature" do
# we're implementing the same signature mechanism as GitHub, so might as well re-use their
# example. https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
e = Webhook::Executor.new('https://example.com', 'Hello, World!', "It's a Secret to Everybody")
assert_equal "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17", e.compute_signature
end
private
def create_hook(url: 'https://example.com/some/hook', user: User.find_by_login('dlopper'), projects: [Project.find('ecookbook')], events: ['issue.created'], active: true)
Webhook.create!(url: url, user: user, projects: projects, events: events, active: active)
end
end