Maintenance: Allow alternative FQDN for WebSocket origins.
Approved-by: Florian Liebe <fl@zammad.com> Co-authored-by: Florian Liebe <fl@zammad.com>
This commit is contained in:
@@ -21,7 +21,7 @@ Rails.application.reloader.to_prepare do
|
||||
Rails.application.config.action_cable.allow_same_origin_as_host = true
|
||||
# Support for configurations where the HTTP_HOST header is not correctly forwarded:
|
||||
request_origins = [%r{https?://localhost:\d+}]
|
||||
request_origins << "#{Setting.get('http_type')}://#{Setting.get('fqdn')}"
|
||||
request_origins.concat(Zammad::WebSocketOrigins.configured)
|
||||
Rails.application.config.action_cable.allowed_request_origins = request_origins
|
||||
Rails.application.config.action_cable.disable_request_forgery_protection = true if !Rails.env.production?
|
||||
Rails.logger.info { "ActionCable is configured to accept requests from #{request_origins.join(', ')}." }
|
||||
|
||||
21
db/migrate/20261006120000_add_alternative_fqdn_setting.rb
Normal file
21
db/migrate/20261006120000_add_alternative_fqdn_setting.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class AddAlternativeFqdnSetting < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# return if it's a new setup
|
||||
return if !Setting.exists?(name: 'system_init_done')
|
||||
|
||||
Setting.create_if_not_exists(
|
||||
title: 'Alternative fully qualified domain name',
|
||||
name: 'alternative_fqdn',
|
||||
area: 'System::WebSocket',
|
||||
description: 'Defines an additional fully qualified domain name of the system, which is accepted as an origin for WebSocket connections.',
|
||||
state: '',
|
||||
preferences: {
|
||||
online_service_disable: true,
|
||||
permission: ['admin.system'],
|
||||
},
|
||||
frontend: false
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -335,6 +335,18 @@ Setting.create_if_not_exists(
|
||||
},
|
||||
frontend: true
|
||||
)
|
||||
Setting.create_if_not_exists(
|
||||
title: __('Alternative fully qualified domain name'),
|
||||
name: 'alternative_fqdn',
|
||||
area: 'System::WebSocket',
|
||||
description: __('Defines an additional fully qualified domain name of the system, which is accepted as an origin for WebSocket connections.'),
|
||||
state: '',
|
||||
preferences: {
|
||||
online_service_disable: true,
|
||||
permission: ['admin.system'],
|
||||
},
|
||||
frontend: false
|
||||
)
|
||||
Setting.create_if_not_exists(
|
||||
title: __('WebSocket backend'),
|
||||
name: 'websocket_backend',
|
||||
|
||||
1204
i18n/zammad.pot
1204
i18n/zammad.pot
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ in the same way ApplicationCable::Connection does it. Any session identifier
|
||||
supplied in the event payload is ignored.
|
||||
|
||||
Like ActionCable, the cookie is only trusted when the handshake Origin header
|
||||
matches the configured Zammad origin (or a localhost origin). This prevents a
|
||||
matches a configured Zammad origin (or a localhost origin). This prevents a
|
||||
page on a sibling origin, which browsers still send the SameSite=Lax session
|
||||
cookie to, from adopting the user's websocket session.
|
||||
|
||||
@@ -92,11 +92,7 @@ To execute this manually, just paste the following into the browser console
|
||||
return false if request_origin.blank?
|
||||
return true if LOCALHOST_ORIGIN.match?(request_origin)
|
||||
|
||||
request_origin.casecmp?(zammad_origin)
|
||||
end
|
||||
|
||||
def zammad_origin
|
||||
"#{Setting.get('http_type')}://#{Setting.get('fqdn')}"
|
||||
Zammad::WebSocketOrigins.configured.any? { |origin| request_origin.casecmp?(origin) }
|
||||
end
|
||||
|
||||
def session_cookie_value
|
||||
|
||||
11
lib/zammad/web_socket_origins.rb
Normal file
11
lib/zammad/web_socket_origins.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
module Zammad
|
||||
class WebSocketOrigins
|
||||
|
||||
# The configured Zammad origins accepted by both ActionCable and the legacy websocket server.
|
||||
def self.configured
|
||||
[Setting.get('fqdn'), Setting.get('alternative_fqdn')].compact_blank.map { "#{Setting.get('http_type')}://#{it}" }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,56 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ApplicationCable::Connection, 'origin configuration' do
|
||||
subject(:origin_allowed) { connection.send(:allow_request_origin?) }
|
||||
|
||||
let(:config) { ActionCable::Server::Configuration.new }
|
||||
let(:connection) { described_class.allocate }
|
||||
let(:server) { instance_double(ActionCable::Server::Base, config: config) }
|
||||
let(:alternative_fqdn) { 'tenant.example.org' }
|
||||
let(:origin) { 'https://tenant.example.org' }
|
||||
let(:host) { 'upstream.internal' }
|
||||
|
||||
before do
|
||||
allow(Setting).to receive(:get).and_call_original
|
||||
allow(Setting).to receive(:get).with('http_type').and_return('https')
|
||||
allow(Setting).to receive(:get).with('fqdn').and_return('support.example.org')
|
||||
allow(Setting).to receive(:get).with('alternative_fqdn').and_return(alternative_fqdn)
|
||||
allow(Rails.env).to receive(:production?).and_return(true)
|
||||
allow(Rails.application.config).to receive(:action_cable).and_return(config)
|
||||
allow(Rails.application.reloader).to receive(:to_prepare).and_yield
|
||||
allow(Zammad::Service::Redis).to receive(:new).and_return(instance_double(Zammad::Service::Redis, ping: true))
|
||||
|
||||
load Rails.root.join('config/initializers/zzz_action_cable_preferences.rb')
|
||||
|
||||
allow(connection).to receive_messages(
|
||||
server: server,
|
||||
logger: Rails.logger,
|
||||
env: { 'HTTP_ORIGIN' => origin, 'HTTP_HOST' => host, 'rack.url_scheme' => 'https' },
|
||||
)
|
||||
end
|
||||
|
||||
it 'accepts the alternative origin when the proxy rewrites the host' do
|
||||
expect(origin_allowed).to be true
|
||||
end
|
||||
|
||||
context 'with the canonical origin' do
|
||||
let(:origin) { 'https://support.example.org' }
|
||||
|
||||
it { is_expected.to be true }
|
||||
end
|
||||
|
||||
context 'with a same-origin request outside the explicit allowlist' do
|
||||
let(:origin) { 'https://another.example.org' }
|
||||
let(:host) { 'another.example.org' }
|
||||
|
||||
it { is_expected.to be true }
|
||||
end
|
||||
|
||||
context 'with a foreign origin' do
|
||||
let(:origin) { 'https://evil.example.org' }
|
||||
|
||||
it { is_expected.to be false }
|
||||
end
|
||||
end
|
||||
21
spec/db/migrate/add_alternative_fqdn_setting_spec.rb
Normal file
21
spec/db/migrate/add_alternative_fqdn_setting_spec.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AddAlternativeFqdnSetting, db_strategy: :reset, type: :db_migration do
|
||||
before do
|
||||
Setting.find_by(name: 'alternative_fqdn')&.destroy!
|
||||
end
|
||||
|
||||
it 'creates the alternative FQDN setting' do
|
||||
expect { migrate }
|
||||
.to change { Setting.exists?(name: 'alternative_fqdn') }
|
||||
.to(true)
|
||||
end
|
||||
|
||||
it 'restricts changes to system administrators outside of the hosted service' do
|
||||
migrate
|
||||
|
||||
expect(Setting.find_by(name: 'alternative_fqdn').preferences).to include(online_service_disable: true, permission: ['admin.system'])
|
||||
end
|
||||
end
|
||||
@@ -74,6 +74,29 @@ RSpec.describe Sessions::Event::Login do
|
||||
include_examples 'starting a session for the user'
|
||||
end
|
||||
|
||||
context 'with an alternative FQDN configured' do
|
||||
let(:alternative_fqdn) { 'support.example.org' }
|
||||
|
||||
before do
|
||||
allow(Setting).to receive(:get).and_call_original
|
||||
allow(Setting).to receive(:get).with('alternative_fqdn').and_return(alternative_fqdn)
|
||||
end
|
||||
|
||||
include_examples 'starting a session for the user'
|
||||
|
||||
context 'with the alternative origin' do
|
||||
let(:origin) { "#{Setting.get('http_type')}://#{alternative_fqdn}" }
|
||||
|
||||
include_examples 'starting a session for the user'
|
||||
end
|
||||
|
||||
context 'with an origin only sharing the prefix of the alternative origin' do
|
||||
let(:origin) { "#{Setting.get('http_type')}://#{alternative_fqdn}.evil.example.org" }
|
||||
|
||||
include_examples 'starting a session without a user'
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a localhost origin' do
|
||||
let(:headers) { { 'Origin' => 'https://localhost:3001', 'Cookie' => session_cookie } }
|
||||
|
||||
|
||||
30
spec/lib/zammad/web_socket_origins_spec.rb
Normal file
30
spec/lib/zammad/web_socket_origins_spec.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Zammad::WebSocketOrigins do
|
||||
describe '.configured' do
|
||||
let(:alternative_fqdn) { 'tenant.example.org' }
|
||||
|
||||
before do
|
||||
allow(Setting).to receive(:get).and_call_original
|
||||
allow(Setting).to receive(:get).with('http_type').and_return('https')
|
||||
allow(Setting).to receive(:get).with('fqdn').and_return('support.example.org')
|
||||
allow(Setting).to receive(:get).with('alternative_fqdn').and_return(alternative_fqdn)
|
||||
end
|
||||
|
||||
it 'returns the canonical and the alternative origin' do
|
||||
expect(described_class.configured).to eq(['https://support.example.org', 'https://tenant.example.org'])
|
||||
end
|
||||
|
||||
[nil, '', ' '].each do |value|
|
||||
context "with alternative_fqdn set to #{value.inspect}" do
|
||||
let(:alternative_fqdn) { value }
|
||||
|
||||
it 'returns only the canonical origin' do
|
||||
expect(described_class.configured).to eq(['https://support.example.org'])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -36,6 +36,7 @@ RSpec.describe SystemReport, current_user_id: 1, type: :model do
|
||||
'ui_ticket_zoom_article_email_subject',
|
||||
'system_id',
|
||||
'fqdn',
|
||||
'alternative_fqdn',
|
||||
'websocket_backend',
|
||||
'websocket_port',
|
||||
'http_type',
|
||||
|
||||
Reference in New Issue
Block a user