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:
Dominik Klein
2026-10-06 18:19:47 +02:00
parent e732fa5107
commit ee95993569
11 changed files with 784 additions and 605 deletions

View File

@@ -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(', ')}." }

View 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

View File

@@ -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',

File diff suppressed because it is too large Load Diff

View File

@@ -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

View 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

View File

@@ -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

View 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

View File

@@ -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 } }

View 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

View File

@@ -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',