[privacy] remove Community Chatwoot Hub egress

Remove Community Chatwoot Hub URL/push relay/sync/registration/event/changelog
egress. changelog.js becomes a local empty-feed adapter (no axios/fetch/network)
exporting the default ChangelogApi; links.js drops the Hub changelog URL.
lib/chatwoot_hub.rb removes base_url/push_notification_url/billing_base_url/
instance_config/send_push/send_push_with_response; billing_url reads only an
explicit CHATWOOT_BILLING_URL env, HTTPS-only with host and no userinfo, and
never falls back to a Hub URL. Enterprise proprietary base_url override is
preserved (spec uses singleton_class.instance_methods(false) for edition-safety).
privacy_audit uses a narrow per-file/per-rule Enterprise exception (hub-url
only) and privacy_audit_test.sh proves forbidden Enterprise runtime lines are
still detected; deployment privacy guard unchanged. Approved by independent
five-key review deleg_49d6ee2e (passed=true, blocking arrays empty).
This commit is contained in:
Kunthawat Greethong
2026-08-16 07:37:52 +07:00
parent 2ef6fa554b
commit 8101395608
10 changed files with 146 additions and 73 deletions

View File

@@ -1,15 +1,8 @@
import axios from 'axios';
import ApiClient from './ApiClient';
import { CHANGELOG_API_URL } from 'shared/constants/links';
class ChangelogApi extends ApiClient {
constructor() {
super('changelog', { apiVersion: 'v1' });
}
class ChangelogApi {
// Keep the legacy API surface, but never fetch a vendor-owned changelog.
// eslint-disable-next-line class-methods-use-this
fetchFromHub() {
return axios.get(CHANGELOG_API_URL);
return Promise.resolve({ data: { posts: [] } });
}
}

View File

@@ -0,0 +1,24 @@
import axios from 'axios';
import changelogAPI from '../changelog';
vi.mock('axios');
describe('#changelogAPI', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({}));
axios.get.mockResolvedValue({ data: { posts: [] } });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('returns an empty local feed without making a request', async () => {
const response = await changelogAPI.fetchFromHub();
expect(response).toEqual({ data: { posts: [] } });
expect(global.fetch).not.toHaveBeenCalled();
expect(axios.get).not.toHaveBeenCalled();
});
});

View File

@@ -7,5 +7,3 @@ export const REPLY_POLICY = {
'https://business.whatsapp.com/policy#:~:text=You%20may%20reply%20to%20a,messages%20via%20approved%20Message%20Templates.',
TIKTOK: 'https://business-api.tiktok.com/portal/docs?id=1832184236919810',
};
export const CHANGELOG_API_URL = 'https://hub.2.chatwoot.com/changelogs';

View File

@@ -1,4 +1,6 @@
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer" class="flex gap-1 items-center bg-slate-100 h-9 hover:bg-slate-300 hover:text-n-slate-12 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2 focus:outline-none">
<% if (billing_url = ChatwootHub.billing_url).present? %>
<a href="<%= billing_url %>" target="_blank" rel="noopener noreferrer" class="flex gap-1 items-center bg-slate-100 h-9 hover:bg-slate-300 hover:text-n-slate-12 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2 focus:outline-none">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l-2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
<span class="px-1">Upgrade now</span>
</a>
<% end %>

View File

@@ -47,12 +47,14 @@
</div>
<p class="text-n-slate-11 m-0 text-sm"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p>
</div>
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer">
<% if (billing_url = ChatwootHub.billing_url).present? %>
<a href="<%= billing_url %>" target="_blank" rel="noopener noreferrer">
<button class="mt-4 md:mt-0 flex gap-1 items-center bg-transparent shadow-sm h-9 hover:text-n-slate-12 hover:bg-slate-50 outline outline-1 outline-n-container rounded text-n-slate-11 font-medium p-2 focus:outline-none">
<svg width="16" height="16"><use xlink:href="#icon-settings-2-line" /></svg>
<span class="px-1">Manage</span>
</button>
</a>
<% end %>
</div>
<% end %>

View File

@@ -1,28 +1,12 @@
require 'uri'
class ChatwootHub
DEFAULT_BASE_URL = 'https://hub.2.chatwoot.com'.freeze
def self.base_url
DEFAULT_BASE_URL
end
def self.push_notification_url
"#{base_url}/send_push"
end
def self.billing_base_url
"#{base_url}/billing"
end
def self.installation_identifier
identifier = InstallationConfig.find_by(name: 'INSTALLATION_IDENTIFIER')&.value
identifier ||= InstallationConfig.create!(name: 'INSTALLATION_IDENTIFIER', value: SecureRandom.uuid).value
identifier
end
def self.billing_url
"#{billing_base_url}?installation_identifier=#{installation_identifier}"
end
def self.pricing_plan
return 'community' unless ChatwootApp.enterprise?
@@ -35,6 +19,18 @@ class ChatwootHub
InstallationConfig.find_by(name: 'INSTALLATION_PRICING_PLAN_QUANTITY')&.value || 0
end
def self.billing_url
configured_url = ENV['CHATWOOT_BILLING_URL'].to_s
return if configured_url.empty?
uri = URI.parse(configured_url)
return unless uri.scheme == 'https' && uri.host.present? && uri.user.nil? && uri.password.nil?
configured_url
rescue URI::InvalidURIError
nil
end
def self.support_config
{
support_website_token: InstallationConfig.find_by(name: 'CHATWOOT_SUPPORT_WEBSITE_TOKEN')&.value,
@@ -43,28 +39,6 @@ class ChatwootHub
}
end
def self.instance_config
{
installation_identifier: installation_identifier,
installation_version: Chatwoot.config[:version],
installation_host: URI.parse(ENV.fetch('FRONTEND_URL', '')).host,
installation_env: ENV.fetch('INSTALLATION_ENV', ''),
edition: ENV.fetch('CW_EDITION', '')
}
end
def self.send_push(fcm_options)
send_push_with_response(fcm_options)
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.error "Exception: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
end
def self.send_push_with_response(fcm_options)
info = { fcm_options: fcm_options }
RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
end
end
ChatwootHub.singleton_class.prepend_mod_with('ChatwootHub')

View File

@@ -33,7 +33,27 @@ VISIBLE_BRANDING = re.compile(r"\bChatwoot\b")
VISIBLE_ROOTS = ("app/views", "app/javascript", "config/locales", "public")
BUILT_ROOTS = ("public/assets", "public/packs", "public/vite")
SKIP_PARTS = {".git", "node_modules", ".pnpm-store", "tmp", "log", "coverage", "storage"}
DEFAULT_ALLOWED_PATHS = {"LICENSE", "script/privacy_audit", "script/privacy_audit_test.sh"}
DEFAULT_ALLOWED_PATHS = {
"LICENSE",
"script/privacy_audit",
"script/privacy_audit_test.sh",
"deployment/spec/setup_20.04_privacy_test.sh",
# Historical planning/evidence files may quote removed endpoints or SDKs;
# runtime source and built artifacts are never covered by this exception.
".hermes/plans/2026-08-15_092534-chatwoot-private-rebrand.md",
".hermes/plans/chatwoot-private/06-remove-amplitude.md",
# The log records removed symbols; Enterprise retains proprietary Hub code
# outside the Community release boundary and is reviewed separately.
"engineering-log.md",
"spec/enterprise/lib/chatwoot_hub_spec.rb",
}
# Enterprise keeps a proprietary Hub boundary outside the Community release.
# Allow only the known boundary URL; all other privacy rules still scan this
# runtime file so adding a forbidden SDK or event reporter cannot be hidden.
DEFAULT_ALLOWED_RULES = {
"enterprise/lib/enterprise/chatwoot_hub.rb": {"hub-url"},
}
def parse_args() -> argparse.Namespace:
@@ -176,12 +196,13 @@ def main() -> int:
text = read_text(path)
if text is None:
continue
allowed = relative in allowlist
allowed_path = relative in allowlist
allowed_rules = DEFAULT_ALLOWED_RULES.get(relative, set())
for line_number, line in enumerate(text.splitlines(), start=1):
for category, pattern in RULES:
if pattern.search(line):
if allowed:
if allowed_path or category in allowed_rules:
continue
prefix = "REPORT" if args.report_only else "FAIL"
print(f"{prefix} {relative}:{line_number}:{category}")

View File

@@ -7,7 +7,8 @@ ROOT=$(mktemp -d "${TMPDIR:-/tmp}/privacy-audit-test.XXXXXX")
OUTSIDE=$(mktemp "${TMPDIR:-/tmp}/privacy-audit-outside.XXXXXX")
OUTSIDE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/privacy-audit-outside-dir.XXXXXX")
TRACKED_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/privacy-audit-tracked.XXXXXX")
trap 'rm -rf "$ROOT" "$OUTSIDE" "$OUTSIDE_DIR" "$TRACKED_ROOT"' EXIT
ENTERPRISE_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/privacy-audit-enterprise.XXXXXX")
trap 'rm -rf "$ROOT" "$OUTSIDE" "$OUTSIDE_DIR" "$TRACKED_ROOT" "$ENTERPRISE_ROOT"' EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
@@ -33,6 +34,18 @@ printf '%s\n' 'docs/audit-record.md' > "$ROOT/allowlist.txt"
printf '%s\n' 'https://hub.2.chatwoot.com/ping' > "$ROOT/docs/audit-record.md"
python3 "$AUDIT" --root "$ROOT" --allowlist-file "$ROOT/allowlist.txt" >/dev/null || fail 'explicitly allowlisted audit document must pass'
mkdir -p "$ENTERPRISE_ROOT/enterprise/lib/enterprise"
git -C "$ENTERPRISE_ROOT" init -q
printf '%s\n%s\n' 'https://hub.2.chatwoot.com/ping' "import '@sentry/vue'" > "$ENTERPRISE_ROOT/enterprise/lib/enterprise/chatwoot_hub.rb"
git -C "$ENTERPRISE_ROOT" add enterprise/lib/enterprise/chatwoot_hub.rb
set +e
enterprise_output=$(python3 "$AUDIT" --root "$ENTERPRISE_ROOT" 2>&1)
enterprise_exit=$?
set -e
[ "$enterprise_exit" -ne 0 ] || fail 'enterprise runtime must not be exempt from every privacy rule'
printf '%s\n' "$enterprise_output" | grep -F 'enterprise/lib/enterprise/chatwoot_hub.rb:2:sentry-sdk' >/dev/null || fail 'enterprise runtime Sentry finding was not reported'
printf '%s\n' "$enterprise_output" | grep -F 'enterprise/lib/enterprise/chatwoot_hub.rb:1:hub-url' >/dev/null && fail 'intentional enterprise Hub boundary was not narrowly allowlisted'
printf '%s\n' 'https://hub.2.chatwoot.com/ping' > "$OUTSIDE"
set +e
outside_allowlist_status=$(python3 "$AUDIT" --root "$ROOT" --allowlist-file "$OUTSIDE" >/dev/null 2>&1)

View File

@@ -10,4 +10,12 @@ RSpec.describe SuperAdmin::SettingsController do
expect(template).not_to include('refresh_super_admin_settings_url')
end
it 'renders billing links only when an explicit validated URL exists' do
settings_template = Rails.root.join('app/views/super_admin/settings/show.html.erb').read
upgrade_template = Rails.root.join('app/views/super_admin/settings/_upgrade_button_enterprise.html.erb').read
expect(settings_template).to include('(billing_url = ChatwootHub.billing_url).present?')
expect(upgrade_template).to include('(billing_url = ChatwootHub.billing_url).present?')
end
end

View File

@@ -7,15 +7,53 @@ describe ChatwootHub do
expect(described_class).not_to respond_to(:emit_event)
end
it 'retains push and instance compatibility APIs for the push-relay task' do
expect(described_class).to respond_to(:push_notification_url, :send_push, :send_push_with_response, :instance_config)
expect(described_class.push_notification_url).to eq('https://hub.2.chatwoot.com/send_push')
it 'does not expose remote URL constructors or a push relay' do
expect(described_class).not_to respond_to(
:billing_base_url,
:push_notification_url,
:send_push,
:send_push_with_response,
:instance_config
)
# Enterprise may prepend its proprietary base_url compatibility method.
# Check only methods defined directly by the Community singleton class so
# the shared spec remains valid in both editions.
expect(described_class.singleton_class.instance_methods(false)).not_to include(:base_url)
expect(described_class).to respond_to(:billing_url)
end
describe '.base_url' do
it 'uses the static hub url' do
expect(described_class::DEFAULT_BASE_URL).to eq('https://hub.2.chatwoot.com')
expect(described_class.base_url).to eq('https://hub.2.chatwoot.com')
it 'keeps pricing reads local to the installation' do
expect(described_class.pricing_plan).to eq('community')
expect(described_class.pricing_plan_quantity).to eq(0)
end
describe '.billing_url' do
around do |example|
previous = ENV['CHATWOOT_BILLING_URL']
example.run
ENV['CHATWOOT_BILLING_URL'] = previous
end
it 'returns no link unless an administrator explicitly configures one' do
ENV.delete('CHATWOOT_BILLING_URL')
expect(described_class.billing_url).to be_nil
end
it 'accepts an explicitly configured HTTPS link' do
ENV['CHATWOOT_BILLING_URL'] = 'https://billing.example.test/manage'
expect(described_class.billing_url).to eq('https://billing.example.test/manage')
end
it 'rejects non-HTTPS and malformed links' do
ENV['CHATWOOT_BILLING_URL'] = 'http://billing.example.test/manage'
expect(described_class.billing_url).to be_nil
ENV['CHATWOOT_BILLING_URL'] = 'not-a-url'
expect(described_class.billing_url).to be_nil
end
end